Skip to content

Commit

Permalink
metal: simple IPAM for IPv6
Browse files Browse the repository at this point in the history
  • Loading branch information
justinsb committed Nov 12, 2024
1 parent 8820e1c commit 2e6784b
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 20 deletions.
2 changes: 1 addition & 1 deletion cmd/kops-controller/controllers/gceipam.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func NewGCEIPAMReconciler(mgr manager.Manager) (*GCEIPAMReconciler, error) {
return r, nil
}

// GCEIPAMReconciler observes Node objects, assigning their`PodCIDRs` from the instance's `ExternalIpv6`.
// GCEIPAMReconciler observes Node objects, assigning their `PodCIDRs` from the instance's `ExternalIpv6`.
type GCEIPAMReconciler struct {
// client is the controller-runtime client
client client.Client
Expand Down
103 changes: 103 additions & 0 deletions cmd/kops-controller/controllers/metalipam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
Copyright 2024 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controllers

import (
"context"
"fmt"

"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/klog/v2"
kopsapi "k8s.io/kops/pkg/apis/kops/v1alpha2"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/manager"
)

// NewMetalIPAMReconciler is the constructor for a MetalIPAMReconciler
func NewMetalIPAMReconciler(ctx context.Context, mgr manager.Manager) (*MetalIPAMReconciler, error) {
klog.Info("starting metal ipam controller")
r := &MetalIPAMReconciler{
client: mgr.GetClient(),
log: ctrl.Log.WithName("controllers").WithName("metal_ipam"),
}

coreClient, err := corev1client.NewForConfig(mgr.GetConfig())
if err != nil {
return nil, fmt.Errorf("building corev1 client: %w", err)
}
r.coreV1Client = coreClient

return r, nil
}

// MetalIPAMReconciler observes Node objects, assigning their `PodCIDRs` from the instance's `ExternalIpv6`.
type MetalIPAMReconciler struct {
// client is the controller-runtime client
client client.Client

// log is a logr
log logr.Logger

// coreV1Client is a client-go client for patching nodes
coreV1Client *corev1client.CoreV1Client
}

// +kubebuilder:rbac:groups=,resources=nodes,verbs=get;list;watch;patch
// Reconcile is the main reconciler function that observes node changes.
func (r *MetalIPAMReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
node := &corev1.Node{}
if err := r.client.Get(ctx, req.NamespacedName, node); err != nil {
klog.Warningf("unable to fetch node %s: %v", node.Name, err)
if apierrors.IsNotFound(err) {
// we'll ignore not-found errors, since they can't be fixed by an immediate
// requeue (we'll need to wait for a new notification), and we can get them
// on deleted requests.
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

host := &kopsapi.Host{}
id := types.NamespacedName{
Namespace: "kops-system",
Name: node.Name,
}
if err := r.client.Get(ctx, id, host); err != nil {
klog.Warningf("unable to fetch host %s: %v", id, err)
return ctrl.Result{}, err
}

if len(node.Spec.PodCIDRs) == 0 {
if err := patchNodePodCIDRs(r.coreV1Client, ctx, node, host.Spec.PodCIDRs); err != nil {
return ctrl.Result{}, err
}
}

return ctrl.Result{}, nil
}

func (r *MetalIPAMReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("metal_ipam").
For(&corev1.Node{}).
Complete(r)
}
6 changes: 6 additions & 0 deletions cmd/kops-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ func setupCloudIPAM(ctx context.Context, mgr manager.Manager, opt *config.Option
return fmt.Errorf("creating gce IPAM controller: %w", err)
}
controller = ipamController
case "metal":
ipamController, err := controllers.NewMetalIPAMReconciler(ctx, mgr)
if err != nil {
return fmt.Errorf("creating metal IPAM controller: %w", err)
}
controller = ipamController
default:
return fmt.Errorf("kOps IPAM controller is not supported on cloud %q", opt.Cloud)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/kops/toolbox_enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func NewCmdToolboxEnroll(f commandutils.Factory, out io.Writer) *cobra.Command {

cmd.Flags().StringVar(&options.ClusterName, "cluster", options.ClusterName, "Name of cluster to join")
cmd.Flags().StringVar(&options.InstanceGroup, "instance-group", options.InstanceGroup, "Name of instance-group to join")
cmd.Flags().StringSliceVar(&options.PodCIDRs, "pod-cidr", options.PodCIDRs, "IP Address range to use for pods that run on this node")

cmd.Flags().StringVar(&options.Host, "host", options.Host, "IP/hostname for machine to add")
cmd.Flags().StringVar(&options.SSHUser, "ssh-user", options.SSHUser, "user for ssh")
Expand Down
1 change: 1 addition & 0 deletions docs/cli/kops_toolbox_enroll.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions k8s/crds/kops.k8s.io_hosts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ spec:
properties:
instanceGroup:
type: string
podCIDRs:
description: PodCIDRs configures the IP ranges to be used for pods
on this node/host.
items:
type: string
type: array
publicKey:
type: string
type: object
Expand Down
58 changes: 54 additions & 4 deletions nodeup/pkg/model/kube_apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ package model
import (
"context"
"fmt"
"net"
"path/filepath"
"sort"
"strings"

"k8s.io/klog/v2"
"k8s.io/kops/pkg/apis/kops"
"k8s.io/kops/pkg/flagbuilder"
"k8s.io/kops/pkg/k8scodecs"
Expand Down Expand Up @@ -77,6 +79,55 @@ func (b *KubeAPIServerBuilder) Build(c *fi.NodeupModelBuilderContext) error {
}
}

if b.CloudProvider() == kops.CloudProviderMetal {
// Workaround for https://github.com/kubernetes/kubernetes/issues/111671
if b.IsIPv6Only() {
interfaces, err := net.Interfaces()
if err != nil {
return fmt.Errorf("getting local network interfaces: %w", err)
}
var ipv6s []net.IP
for _, intf := range interfaces {
addresses, err := intf.Addrs()
if err != nil {
return fmt.Errorf("getting addresses for network interface %q: %w", intf.Name, err)
}
for _, addr := range addresses {
ip, _, err := net.ParseCIDR(addr.String())
if ip == nil {
return fmt.Errorf("parsing ip address %q (bound to network %q): %w", addr.String(), intf.Name, err)
}
if ip.To4() != nil {
// We're only looking for ipv6
continue
}
if ip.IsLinkLocalUnicast() {
klog.V(4).Infof("ignoring link-local unicast addr %v", addr)
continue
}
if ip.IsLinkLocalMulticast() {
klog.V(4).Infof("ignoring link-local multicast addr %v", addr)
continue
}
if ip.IsLoopback() {
klog.V(4).Infof("ignoring loopback addr %v", addr)
continue
}
ipv6s = append(ipv6s, ip)
}
}
if len(ipv6s) > 1 {
klog.Warningf("found multiple ipv6s, choosing first: %v", ipv6s)
}
if len(ipv6s) == 0 {
klog.Warningf("did not find ipv6 address for kube-apiserver --advertise-address")
}
if len(ipv6s) > 0 {
kubeAPIServer.AdvertiseAddress = ipv6s[0].String()
}
}
}

b.configureOIDC(&kubeAPIServer)
if err := b.writeAuthenticationConfig(c, &kubeAPIServer); err != nil {
return err
Expand Down Expand Up @@ -697,10 +748,9 @@ func (b *KubeAPIServerBuilder) buildPod(ctx context.Context, kubeAPIServer *kops
image := b.RemapImage(kubeAPIServer.Image)

container := &v1.Container{
Name: "kube-apiserver",
Image: image,
Env: append(kubeAPIServer.Env, proxy.GetProxyEnvVars(b.NodeupConfig.Networking.EgressProxy)...),
LivenessProbe: livenessProbe,
Name: "kube-apiserver",
Image: image,
Env: append(kubeAPIServer.Env, proxy.GetProxyEnvVars(b.NodeupConfig.Networking.EgressProxy)...), LivenessProbe: livenessProbe,
ReadinessProbe: readinessProbe,
StartupProbe: startupProbe,
Ports: []v1.ContainerPort{
Expand Down
2 changes: 2 additions & 0 deletions nodeup/pkg/model/prefix.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func (b *PrefixBuilder) Build(c *fi.NodeupModelBuilderContext) error {
})
case kops.CloudProviderGCE:
// Prefix is assigned by GCE
case kops.CloudProviderMetal:
// IPv6 must be configured externally (not by nodeup)
default:
return fmt.Errorf("kOps IPAM controller not supported on cloud %q", b.CloudProvider())
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/kops/v1alpha2/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type Host struct {
type HostSpec struct {
PublicKey string `json:"publicKey,omitempty"`
InstanceGroup string `json:"instanceGroup,omitempty"`

// PodCIDRs configures the IP ranges to be used for pods on this node/host.
PodCIDRs []string `json:"podCIDRs,omitempty"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
Expand Down
7 changes: 6 additions & 1 deletion pkg/apis/kops/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pkg/commands/toolbox_enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type ToolboxEnrollOptions struct {

SSHUser string
SSHPort int

// PodCIDRs is the list of IP Address ranges to use for pods that run on this node
PodCIDRs []string
}

func (o *ToolboxEnrollOptions) InitDefaults() {
Expand Down Expand Up @@ -209,6 +212,7 @@ func createHostResourceInAPIServer(ctx context.Context, options *ToolboxEnrollOp
host.Name = nodeName
host.Spec.InstanceGroup = options.InstanceGroup
host.Spec.PublicKey = string(publicKey)
host.Spec.PodCIDRs = options.PodCIDRs

if err := client.Create(ctx, host); err != nil {
return fmt.Errorf("failed to create host %s/%s: %w", host.Namespace, host.Name, err)
Expand Down
16 changes: 14 additions & 2 deletions pkg/kubeconfig/create_kubecfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"crypto/x509/pkix"
"fmt"
"net"
"os/user"
"sort"
"time"
Expand All @@ -41,7 +42,7 @@ func BuildKubecfg(ctx context.Context, cluster *kops.Cluster, keyStore fi.Keysto
server = "https://" + cluster.APIInternalName()
} else {
if cluster.Spec.API.PublicName != "" {
server = "https://" + cluster.Spec.API.PublicName
server = "https://" + wrapIPv6Address(cluster.Spec.API.PublicName)
} else {
server = "https://api." + clusterName
}
Expand Down Expand Up @@ -82,7 +83,7 @@ func BuildKubecfg(ctx context.Context, cluster *kops.Cluster, keyStore fi.Keysto
if len(targets) != 1 {
klog.Warningf("Found multiple API endpoints (%v), choosing arbitrarily", targets)
}
server = "https://" + targets[0]
server = "https://" + wrapIPv6Address(targets[0])
}
}
}
Expand Down Expand Up @@ -171,3 +172,14 @@ func BuildKubecfg(ctx context.Context, cluster *kops.Cluster, keyStore fi.Keysto

return b, nil
}

// wrapIPv6Address will wrap IPv6 addresses in square brackets,
// for use in URLs; other endpoints are unchanged.
func wrapIPv6Address(endpoint string) string {
ip := net.ParseIP(endpoint)
// IPv6 addresses are wrapped in square brackets in URLs
if ip != nil && ip.To4() == nil {
return "[" + endpoint + "]"
}
return endpoint
}
Loading

0 comments on commit 2e6784b

Please sign in to comment.