diff --git a/docs/configuration-directory.md b/docs/configuration-directory.md index 8543caf7..d972d969 100644 --- a/docs/configuration-directory.md +++ b/docs/configuration-directory.md @@ -147,6 +147,7 @@ network: apiVIP: 192.168.122.100 apiHost: api.cluster01.example.com apiVIP6: fd12:3456:789a::21 + apiVIPMode: managed ``` * `manifests` - Optional; Defines remote Kubernetes manifests to be deployed on the cluster. @@ -170,8 +171,9 @@ network: * `type` - Required; Selects the Kubernetes node type, either server (for control plane nodes) or agent (for worker nodes). * `init` - Optional; Indicates which node should function as the cluster initializer. The initializer node is the server node which bootstraps the cluster and allows other nodes to join it. If unset, the first server in the node list will be selected as the initializer. * `network`: - * `apiVIP` - Required for multi-node clusters if not using `apiVIP6`; Specifies the IPv4 address which will serve as the cluster LoadBalancer, backed by MetalLB. - * `apiVIP6` - Required for multi-node clusters if not using `apiVIP`; Specifies the IPv6 address which will serve as the cluster LoadBalancer, backed by MetalLB. + * `apiVIP` - Required for multi-node clusters if not using `apiVIP6`; Specifies the IPv4 address which will serve as the cluster LoadBalancer. By default, setting this field adds MetalLB and endpoint-copier-operator to the image build. + * `apiVIP6` - Required for multi-node clusters if not using `apiVIP`; Specifies the IPv6 address which will serve as the cluster LoadBalancer. By default, setting this field adds MetalLB and endpoint-copier-operator to the image build. + * `apiVIPMode` - Optional; Selects how the API VIP is provided. Omitted or `managed` keeps the default built-in MetalLB and endpoint-copier-operator install. `external` keeps `apiVIP` or `apiVIP6` configured but disables adding those built-in Helm charts at image build time. * `apiHost` - Optional; Specifies the domain address for accessing the cluster. ### Kubernetes Directory diff --git a/internal/config/v0/config.go b/internal/config/v0/config.go index c8992585..fd0f41e3 100644 --- a/internal/config/v0/config.go +++ b/internal/config/v0/config.go @@ -229,7 +229,7 @@ func parseKubernetes(f vfs.FS, configDir Dir, k *kubernetes.Kubernetes, r *relea return fmt.Errorf("reading config file: %w", err) } - if k.Network.APIVIP4 != "" || k.Network.APIVIP6 != "" { + if k.Network.IsHA() && k.Network.APIVIPMode != "external" { containsChart := func(name string) bool { return slices.ContainsFunc(r.Components.HelmCharts, func(c release.HelmChart) bool { return c.Name == name diff --git a/internal/config/v0/config_test.go b/internal/config/v0/config_test.go index da697623..7b9b1bce 100644 --- a/internal/config/v0/config_test.go +++ b/internal/config/v0/config_test.go @@ -21,6 +21,7 @@ import ( "fmt" "path/filepath" "slices" + "strings" "testing" . "github.com/onsi/ginkgo/v2" @@ -174,6 +175,37 @@ var _ = Describe("Configuration", Label("configuration"), func() { })) }) + It("Adds managed HA load balancer charts when apiVIPMode is managed", func() { + clusterYAML := strings.Replace(kubernetesClusterYAML, " apiVIP: 192.168.120.100.sslip.io", " apiVIP: 192.168.120.100.sslip.io\n apiVIPMode: managed", 1) + Expect(fs.WriteFile(configDir.ClusterFilepath(), []byte(clusterYAML), 0644)).To(Succeed()) + + conf, err := Parse(fs, configDir) + Expect(err).ToNot(HaveOccurred()) + + Expect(containsChart("metallb", conf.Release.Components.HelmCharts)).To(BeTrue()) + Expect(containsChart("endpoint-copier-operator", conf.Release.Components.HelmCharts)).To(BeTrue()) + }) + + It("Skips managed HA load balancer charts when apiVIPMode is external", func() { + clusterYAML := strings.Replace(kubernetesClusterYAML, " apiVIP: 192.168.120.100.sslip.io", " apiVIP: 192.168.120.100.sslip.io\n apiVIPMode: external", 1) + Expect(fs.WriteFile(configDir.ClusterFilepath(), []byte(clusterYAML), 0644)).To(Succeed()) + + conf, err := Parse(fs, configDir) + Expect(err).ToNot(HaveOccurred()) + + Expect(containsChart("metallb", conf.Release.Components.HelmCharts)).To(BeFalse()) + Expect(containsChart("endpoint-copier-operator", conf.Release.Components.HelmCharts)).To(BeFalse()) + }) + + It("Fails to parse an invalid apiVIPMode", func() { + clusterYAML := strings.Replace(kubernetesClusterYAML, " apiVIP: 192.168.120.100.sslip.io", " apiVIP: 192.168.120.100.sslip.io\n apiVIPMode: invalid", 1) + Expect(fs.WriteFile(configDir.ClusterFilepath(), []byte(clusterYAML), 0644)).To(Succeed()) + + _, err := Parse(fs, configDir) + + Expect(err).To(MatchError(`validating configuration: validation failed: field "Configuration.Kubernetes.Network.APIVIPMode" must be one of [managed external], but got "invalid"`)) + }) + It("Successfully parses relative release manifest URI", func() { releaseFile := filepath.Join(string(configDir), "release.yaml") releaseYAML := `manifestURI: file://./release-manifest.yaml` diff --git a/internal/image/kubernetes/kubernetes.go b/internal/image/kubernetes/kubernetes.go index a367a7a8..c4e441c0 100644 --- a/internal/image/kubernetes/kubernetes.go +++ b/internal/image/kubernetes/kubernetes.go @@ -133,9 +133,10 @@ func FindInitNode(nodes Nodes) (*Node, error) { } type Network struct { - APIHost string `yaml:"apiHost"` - APIVIP4 string `yaml:"apiVIP,omitempty" validate:"omitempty"` - APIVIP6 string `yaml:"apiVIP6,omitempty" validate:"omitempty,ipv6"` + APIHost string `yaml:"apiHost"` + APIVIP4 string `yaml:"apiVIP,omitempty" validate:"omitempty"` + APIVIP6 string `yaml:"apiVIP6,omitempty" validate:"omitempty,ipv6"` + APIVIPMode string `yaml:"apiVIPMode,omitempty" validate:"omitempty,oneof=managed external"` } func (n Network) IsHA() bool {