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

Dual-stack Handling #612

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

## General

* Added single-stack IPv6 and dual-stack networking support for Kubernetes

## API

### Image Definition Changes

* Added `kubernetes.network.apiVIP6` field to enable cluster LoadBalancer based on IPv6 address
* Added the `enableExtras` flag to enable the SUSE Linux Extras repository during RPM resolution.

### Image Configuration Directory Changes
Expand Down
11 changes: 9 additions & 2 deletions docs/building-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ kubernetes:
version: v1.30.3+rke2r1
network:
apiVIP: 192.168.122.100
apiVIP6: fd12:3456:789a::21
apiHost: api.cluster01.hosted.on.edge.suse.com
nodes:
- hostname: node1.suse.com
Expand Down Expand Up @@ -264,8 +265,12 @@ kubernetes:
* `version` - Required; Specifies the version of a particular K3s or RKE2 release (e.g.`v1.30.3+k3s1` or `v1.30.3+rke2r1`)
* `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.
* `apiVIP` - Required for multi-node clusters if not using `apiVIP6`, optional for single-node clusters, can be
specified alongside `apiVIP6` for dual-stack support; Specifies the IPv4 address which will serve as the cluster
LoadBalancer, backed by MetalLB.
* `apiVIP6` - Required for multi-node clusters if not using `apiVIP`, optional for single-node clusters, can be
specified alongside `apiVIP` for dual-stack support; Specifies the IPv6 address which will serve as the cluster
LoadBalancer, backed by MetalLB.
* `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 Expand Up @@ -469,6 +474,8 @@ defined by the Kubernetes cluster being installed.
* `certs` - Contains certificate files/bundles for TLS verification. Untrusted HTTPS-enabled Helm repositories and
registries must be provided with a certificate file/bundle or require `skipTLSVerify` to be true.

> **_NOTE:_** For dual-stack clusters, a Kubernetes `server.yaml` file is required and it must contain a
> valid dual-stack `service-cidr` and `cluster-cidr` values according to the official [K3s](https://docs.k3s.io/networking/basic-network-options#dual-stack-ipv4--ipv6-networking) and [RKE2](https://docs.rke2.io/networking/basic_network_options#dual-stack-configuration) documentation.
## Elemental

Automatic Elemental registration may be configured for the image. The Elemental registration configuration file,
Expand Down
2 changes: 1 addition & 1 deletion pkg/combustion/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func ComponentHelmCharts(ctx *image.Context) ([]image.HelmChart, []image.HelmRep
var charts []image.HelmChart
var repos []image.HelmRepository

if ctx.ImageDefinition.Kubernetes.Network.APIVIP != "" {
if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 != "" || ctx.ImageDefinition.Kubernetes.Network.APIVIP6 != "" {
metalLBChart := image.HelmChart{
Name: ctx.ArtifactSources.MetalLB.Chart,
RepositoryName: metallbRepositoryName,
Expand Down
84 changes: 70 additions & 14 deletions pkg/combustion/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
k8sAgentConfigFile = "agent.yaml"

k8sInstallScript = "20-k8s-install.sh"
setNodeIPScript = "set-node-ip.sh"
)

var (
Expand All @@ -51,6 +52,9 @@ var (

//go:embed templates/k8s-vip.yaml.tpl
k8sVIPManifest string

//go:embed templates/set-node-ip.sh.tpl
nodeIPScriptTemplate string
)

func (c *Combustion) configureKubernetes(ctx *image.Context) ([]string, error) {
Expand Down Expand Up @@ -145,25 +149,32 @@ func (c *Combustion) configureK3S(ctx *image.Context, cluster *kubernetes.Cluste
return "", fmt.Errorf("configuring kubernetes manifests: %w", err)
}

nodeIPScript, err := createNodeIPScript(ctx, cluster.ServerConfig)
if err != nil {
return "", fmt.Errorf("creating set node IP script: %w", err)
}

templateValues := map[string]any{
"installScript": installScript,
"apiVIP": ctx.ImageDefinition.Kubernetes.Network.APIVIP,
"apiVIP4": ctx.ImageDefinition.Kubernetes.Network.APIVIP4,
"apiVIP6": ctx.ImageDefinition.Kubernetes.Network.APIVIP6,
"apiHost": ctx.ImageDefinition.Kubernetes.Network.APIHost,
"binaryPath": binaryPath,
"imagesPath": imagesPath,
"manifestsPath": manifestsPath,
"configFilePath": prependArtefactPath(k8sDir),
"registryMirrors": prependArtefactPath(filepath.Join(k8sDir, registryMirrorsFileName)),
"setNodeIPScript": nodeIPScript,
}

singleNode := len(ctx.ImageDefinition.Kubernetes.Nodes) < 2
if singleNode {
if ctx.ImageDefinition.Kubernetes.Network.APIVIP == "" {
zap.S().Info("Virtual IP address for k3s cluster is not provided and will not be configured")
if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 == "" && ctx.ImageDefinition.Kubernetes.Network.APIVIP6 == "" {
zap.S().Info("Virtual IP address(es) for k3s cluster not provided and will not be configured")
} else {
log.Audit("WARNING: A Virtual IP address for the k3s cluster has been provided. " +
log.Audit("WARNING: Virtual IP address(es) for the k3s cluster provided. " +
"An external IP address for the Ingress Controller (Traefik) must be manually configured.")
zap.S().Warn("Virtual IP address for k3s cluster is requested and will invalidate Traefik configuration")
zap.S().Warn("Virtual IP address(es) for k3s cluster requested and will invalidate Traefik configuration")
}

templateValues["configFile"] = k8sServerConfigFile
Expand All @@ -172,7 +183,7 @@ func (c *Combustion) configureK3S(ctx *image.Context, cluster *kubernetes.Cluste
}

log.Audit("WARNING: An external IP address for the Ingress Controller (Traefik) must be manually configured in multi-node clusters.")
zap.S().Warn("Virtual IP address for k3s cluster is necessary for multi node clusters and will invalidate Traefik configuration")
zap.S().Warn("Virtual IP address(es) for k3s cluster necessary for multi node clusters and will invalidate Traefik configuration")

templateValues["nodes"] = ctx.ImageDefinition.Kubernetes.Nodes
templateValues["initialiser"] = cluster.InitialiserName
Expand Down Expand Up @@ -240,21 +251,28 @@ func (c *Combustion) configureRKE2(ctx *image.Context, cluster *kubernetes.Clust
return "", fmt.Errorf("configuring kubernetes manifests: %w", err)
}

nodeIPScript, err := createNodeIPScript(ctx, cluster.ServerConfig)
if err != nil {
return "", fmt.Errorf("creating set node IP script: %w", err)
}

templateValues := map[string]any{
"installScript": installScript,
"apiVIP": ctx.ImageDefinition.Kubernetes.Network.APIVIP,
"apiVIP4": ctx.ImageDefinition.Kubernetes.Network.APIVIP4,
"apiVIP6": ctx.ImageDefinition.Kubernetes.Network.APIVIP6,
"apiHost": ctx.ImageDefinition.Kubernetes.Network.APIHost,
"installPath": installPath,
"imagesPath": imagesPath,
"manifestsPath": manifestsPath,
"configFilePath": prependArtefactPath(k8sDir),
"registryMirrors": prependArtefactPath(filepath.Join(k8sDir, registryMirrorsFileName)),
"setNodeIPScript": nodeIPScript,
}

singleNode := len(ctx.ImageDefinition.Kubernetes.Nodes) < 2
if singleNode {
if ctx.ImageDefinition.Kubernetes.Network.APIVIP == "" {
zap.S().Info("Virtual IP address for RKE2 cluster is not provided and will not be configured")
if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 == "" && ctx.ImageDefinition.Kubernetes.Network.APIVIP6 == "" {
zap.S().Info("Virtual IP address(es) for RKE2 cluster not provided and will not be configured")
}

templateValues["configFile"] = k8sServerConfigFile
Expand Down Expand Up @@ -317,16 +335,54 @@ func (c *Combustion) downloadRKE2Artefacts(ctx *image.Context, cluster *kubernet

func kubernetesVIPManifest(k *image.Kubernetes) (string, error) {
manifest := struct {
APIAddress string
RKE2 bool
APIAddress4 string
APIAddress6 string
RKE2 bool
}{
APIAddress: k.Network.APIVIP,
RKE2: strings.Contains(k.Version, image.KubernetesDistroRKE2),
APIAddress4: k.Network.APIVIP4,
APIAddress6: k.Network.APIVIP6,
RKE2: strings.Contains(k.Version, image.KubernetesDistroRKE2),
}

return template.Parse("k8s-vip", k8sVIPManifest, &manifest)
}

func createNodeIPScript(ctx *image.Context, serverConfig map[string]any) (string, error) {
// Setting the Node IP only matters if we're doing dual-stack or single-stack IPv6
if ctx.ImageDefinition.Kubernetes.Network.APIVIP6 == "" {
return "", nil
} else if kubernetes.IsNodeIPSet(serverConfig) {
return "", nil
}

var isIPv4Enabled bool
if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 != "" {
isIPv4Enabled = true
}

manifest := struct {
IPv4Enabled bool
PrioritizeIPv6 bool
RKE2 bool
}{
IPv4Enabled: isIPv4Enabled,
PrioritizeIPv6: kubernetes.IsIPv6Priority(serverConfig),
RKE2: strings.Contains(ctx.ImageDefinition.Kubernetes.Version, image.KubernetesDistroRKE2),
}

data, err := template.Parse("set-node-ip", nodeIPScriptTemplate, &manifest)
if err != nil {
return "", fmt.Errorf("parsing '%s' template: %w", setNodeIPScript, err)
}

nodeIPScript := filepath.Join(ctx.CombustionDir, setNodeIPScript)
if err = os.WriteFile(nodeIPScript, []byte(data), fileio.ExecutablePerms); err != nil {
return "", fmt.Errorf("writing set node IP script: %w", err)
}

return setNodeIPScript, nil
}

func storeKubernetesClusterConfig(cluster *kubernetes.Cluster, destPath string) error {
serverConfig := filepath.Join(destPath, k8sServerConfigFile)
if err := storeKubernetesConfig(cluster.ServerConfig, serverConfig); err != nil {
Expand Down Expand Up @@ -367,7 +423,7 @@ func (c *Combustion) configureManifests(ctx *image.Context) (string, error) {
manifestsPath := localKubernetesManifestsPath()
manifestDestDir := filepath.Join(ctx.ArtefactsDir, manifestsPath)

if ctx.ImageDefinition.Kubernetes.Network.APIVIP != "" {
if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 != "" || ctx.ImageDefinition.Kubernetes.Network.APIVIP6 != "" {
if err := os.MkdirAll(manifestDestDir, os.ModePerm); err != nil {
return "", fmt.Errorf("creating manifests destination dir: %w", err)
}
Expand Down
Loading
Loading