Implementing a CAPI IPAM Provider

Overview

Implementing a CAPI IPAM Provider

One of the oldest problems of Cluster API and Kubernetes cluster creation is managing the Nodes IPs.

While it is possible to rely on DHCP for the node IP allocation, and some other cloud providers have their own way of managing the IPs, for some enterprises and onpremises environments the dynamic IP approach doesn’t applies, and there’s a need for a more strict control on “who is using what IP”.

Additionally, using dynamic IPs may be challenging, as for instance:

  • Some DHCP implementations are not strict on the process of renewal
  • The Kubernetes APIServer endpoint may be static, relying on a static IP or an FQDN associated with an IP
  • Implementing a LoadBalancer controller that uses DHCP is kind of…problematic and complex (but kube-vip does it!!!)

The only other way of doing on Cluster API was to use static IPs on the Machine spec, but this approach reduces the possibility of templating and automating the Cluster creation.

Note: The source code for this PoC is available on https://github.com/rikatz/capi-phpipam/

This first part of the blog post will just implement the IPAM Provider :)

I will probably update it to do some tests against CAPV soon :)

The introduction of Cluster API IPAM

With the challenges above, the community created a new concept on Cluster API core called “IPAM Provider” that allows a user to specify “where should my nodes get their IPs from”.

The following new resources where introduced:

  • ipaddressclaims.ipam.cluster.x-k8s.io/v1alpha1 - A request for a new IP address
    • Contains just an object reference, for “which pool should the IP be allocated”
  • ipaddress.ipam.cluster.x-k8s.io/v1alpha1 - The allocated IP Address
    • Contains a “response” from the IPAM Provider, with the allocated IP address, mask and gateway

So now, basically once you have an IPAM provider running on your cluster, you can create “IPAddressClaims” and receive “IPAddress” back.

So with an IPAM provider running on the cluster, every new Node creation now should, instead of relying on DHCP, request for a new IP address claim, and use the response IPAddress as the node IP.

sequenceDiagram
participant A as Machine
participant B as Infrastructure Provider
participant K as Kubernetes API
participant D as IPAM Provider
  A-->>B: Reconcile Machine request
  B->>K: Create a new claim with a Pool reference
  Note over B, K: IPAddressClaim
  K-->>D: Reconcile IPAddress Claim
  Note over K, D: IPAddressClaim 
  D-->D: Allocate IP based on Pool configuration
  D->>K: Create the allocated IPAddress
  Note over D, K: IPAddress
  D->>A: Add allocated IP Address to underlying infrastructure
  

Developing a new IPAM Provider

Based on the workflow below (and some additional assumptions), we need to support at least the following workflows:

  • Allow a cluster admin to create a new IP pool to be consumed by the infrastructure provider
  • Allow users (or infrastructure provider) to allocate a new IP address from the configured IPPool, to be added to the nodes (or consumed somewhere else)
  • Allow users (or infrastructure provider) to deallocate previously allocated IP addresses

Besides this seems a “simple” workflow, there’s a bunch of logics behind to make it work and adhere with Cluster API contracts.

With this complexity in mind, Cluster API community did a “Reference and Generic implementation” called InCluster provider that can be used both as a real IPAM implementation, as also as a reference library for other providers.

We will be using this library for our example

Our IPAM software

For our implementation, we will use phpIPAM. It is a widely used IPAM, and it can be ran easily with docker-compose. Also, phpIPAM has a simple API that can be used for our needs.

We will not cover phpIPAM installation, but it should be straightforward to get it running.

In the end of phpIPAM installation and configuration we should have a subnet configured and ready to be used as:

PHPIPAM
Note The API management needs to be enabled on phpIPAM, under Administration / phpIPAM settings. Then a new API Key should be created under Administration / API, with the type “User token”. Note2 If you are using docker-compose, inside the container, on the file /phpipam/config.dist.php change the directive api_allow_unsafe to true

Creating our phpIPAM Go client

As we will need to allocate and deallocate IPAddress, let’s write our phpIPAM Client:

  1package ipamclient
  2
  3import (
  4	"fmt"
  5
  6	"github.com/pavel-z1/phpipam-sdk-go/controllers/addresses"
  7	"github.com/pavel-z1/phpipam-sdk-go/controllers/subnets"
  8	"github.com/pavel-z1/phpipam-sdk-go/phpipam"
  9	"github.com/pavel-z1/phpipam-sdk-go/phpipam/session"
 10	"github.com/rikatz/capi-phpipam/api/v1alpha1"
 11)
 12
 13type IPAMClient struct {
 14	subnetid int
 15	ctrl     *addresses.Controller
 16	subctrl  *subnets.Controller
 17}
 18
 19type Subnet struct {
 20	Mask    string `json:"mask,omitempty"`
 21	Gateway struct {
 22		IPAddress string `json:"ip_addr,omitempty"`
 23	} `json:"gateway,omitempty"`
 24}
 25
 26type addrId struct {
 27	ID        int    `json:"id,omitempty"`
 28	SubnetID  int    `json:"subnetId,omitempty"`
 29	IPAddress string `json:"ip,omitempty"`
 30}
 31
 32// We create our new client with all the controllers already encapsulated
 33// TODO: Should support HTTPs and skip insecure :)
 34func NewIPAMClient(cfg phpipam.Config, subnetid int) *IPAMClient {
 35	sess := session.NewSession(cfg)
 36	return &IPAMClient{
 37		subnetid: subnetid,
 38		ctrl:     addresses.NewController(sess),
 39		subctrl:  subnets.NewController(sess),
 40	}
 41}
 42
 43func (i *IPAMClient) GetAddress(hostname string) (string, error) {
 44	myaddr := make([]addrId, 0)
 45
 46    // We just return a new address if it doesn't already exists
 47	err := i.ctrl.SendRequest("GET", fmt.Sprintf("/addresses/search_hostname/%s", hostname), &struct{}{}, &myaddr)
 48	if err == nil && len(myaddr) > 0 && myaddr[0].SubnetID == i.subnetid {
 49		return myaddr[0].IPAddress, nil
 50	}
 51
 52	addr, err := i.ctrl.CreateFirstFreeAddress(i.subnetid, addresses.Address{Description: hostname, Hostname: hostname})
 53	if err != nil {
 54		return "", err
 55	}
 56	return addr, nil
 57}
 58
 59func (i *IPAMClient) ReleaseAddress(hostname string) error {
 60	// The library is broken on addrStruct so we need a simple one just to get the allocated ID
 61	// TODO: Improve error handling, being able to check if the error is something like "not found"
 62	myaddr, err := i.searchForAddress(hostname)
 63	if err != nil {
 64		return fmt.Errorf("failed to find the address, maybe it doesn't exist anymore? %w", err)
 65	}
 66
 67	_, err = i.ctrl.DeleteAddress(myaddr.ID, false)
 68	if err != nil {
 69		return err
 70	}
 71	return nil
 72}
 73
 74func (i *IPAMClient) GetSubnetConfig() (*Subnet, error) {
 75	var subnet Subnet
 76	err := i.subctrl.SendRequest("GET", fmt.Sprintf("/subnets/%d/", i.subnetid), &struct{}{}, &subnet)
 77	if err != nil {
 78		return nil, err
 79	}
 80	return &subnet, nil
 81}
 82
 83func (i *IPAMClient) searchForAddress(hostname string) (*addrId, error) {
 84	myaddr := make([]addrId, 0)
 85
 86	err := i.ctrl.SendRequest("GET", fmt.Sprintf("/addresses/search_hostname/%s", hostname), &struct{}{}, &myaddr)
 87	if err == nil && len(myaddr) > 0 && myaddr[0].SubnetID == i.subnetid {
 88		return &myaddr[0], nil
 89	}
 90	return nil, err
 91}
 92
 93func SpecToClient(spec *v1alpha1.PHPIPAMPoolSpec) (*IPAMClient, error) {
 94	if spec == nil {
 95		return nil, fmt.Errorf("spec cannot be null")
 96	}
 97
 98	if spec.SubnetID < 0 || spec.Credentials == nil {
 99		return nil, fmt.Errorf("subnet id and credentials are required")
100	}
101
102	return NewIPAMClient(phpipam.Config{
103		AppID:    spec.Credentials.AppID,
104		Username: spec.Credentials.Username,
105		Password: spec.Credentials.Password,
106		Endpoint: spec.Credentials.Endpoint,
107	}, spec.SubnetID), nil
108}```
109
110This library will be responsible to allocate / deallocate addresses and get the right configurations from our subnet.
111**Note** This code may have changed over time, look at the repo for the latest available version
112### Writing the CAPI IPAM controller
113Writing the IPAM controller starts with the definition of the Pool API and controller, which will be the resource where the cluster admin will define the IPAM software configuration.
114
115After that, CAPI IPAM requires two more implementations: the Claim Handler, responsible for getting and releasing an IP address from our IPAM, and the IPAM Adapter, that will serve as a middle layer between CAPI IPAM generic controller and our specific IPAM implementation. 
116
117#### The Pool controller
118As part of the implementation, we need a "way" to let the controller know which IPAddress it owns, and also how it should allocate IP Addresses and communicate with the underlying IPAM. Let's take as an example the definition of an in-cluster IPPool:
119
120```yaml
121apiVersion: ipam.cluster.x-k8s.io/v1alpha2
122kind: InClusterIPPool
123metadata:
124  name: inclusterippool-sample
125spec:
126  addresses:
127    - 10.0.0.0/24
128  prefix: 24
129  gateway: 10.0.0.1

Looking at this IPPool example, we can see the definition of “how the controller should behave when allocating IPs”. Later on, we refer inclusterippool-sample when requesting new IPs.

For phpIPAM we have already configured our IPPool, but we need to tell the controller some other informations, like “what credentials to use” and “what subnet should be consumed”. Something as below (not ideal, I know, but good for our experiment)

 1apiVersion: ipam.cluster.x-k8s.io/v1alpha2
 2kind: PHPIPAMPool
 3metadata:
 4  name: mypool
 5spec:
 6  subnetid: 7 # You get this from phpipam UI
 7  appid: capi123 # Created with the APIKey when configuring PHPIPAM
 8  username: admin # You shouldn't do it, but I'm lazy!
 9  password: password
10  endpoint: http://127.0.0.1/api

I’m not going to write the whole go code for the API spec, but it should be available on Github. On the implementation of the PHPIPAMIPPool reconciliation, what matters to us is:

 1// Reconcile the IPPool and set it as ready
 2func (r *PHPIPAMIPPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
 3
 4	ippoollogger = log.FromContext(ctx).WithName("PHPIPAM ippool")
 5	ippoollogger.Info("received reconciliation", "request", req.NamespacedName.String())
 6
 7	var ippool ipamv1alpha1.PHPIPAMIPPool
 8	if err := r.Get(ctx, req.NamespacedName, &ippool); err != nil {
 9		if k8serrors.IsNotFound(err) {
10			return ctrl.Result{}, nil
11		}
12		ippoollogger.Error(err, "unable to get ippool")
13		return ctrl.Result{}, err
14	}
15
16	ipamcl, err := ipamclient.SpecToClient(&ippool.Spec)
17	if err != nil {
18		return r.ConditionsWithErrors(ctx, req, &ippool, ipamv1alpha1.ConditionReasonInvalidPHPIPam, "PHPIPAMconfig configuration is invalid: "+err.Error(), true)
19
20	}
21
22	subnetCfg, err := ipamcl.GetSubnetConfig()
23	if err != nil {
24		return r.ConditionsWithErrors(ctx, req, &ippool, ipamv1alpha1.ConditionReasonInvalidCreds, "failed to login to phpipam: "+err.Error(), false)
25	}
26	ippool.Status.Gateway = subnetCfg.Gateway.IPAddress
27	ippool.Status.Mask = subnetCfg.Mask
28	r.ipamcl = ipamcl
29	return r.SetReady(ctx, req, &ippool)
30}

After installing the CRDs, building and running the controller, we can create the following IPPool and check if the controller is able to reconcile it:

 1apiVersion: ipam.cluster.x-k8s.io/v1alpha1
 2kind: PHPIPAMIPPool
 3metadata:
 4  name: my-ipam-test
 5spec:
 6  subnetid: 7
 7  credentials:
 8    username: admin
 9    password: "12qw!@QW"
10    app_id: capi123
11    endpoint: "http://127.0.0.1/api"

And getting its status should return:

1status:
2  conditions:
3  - lastTransitionTime: "2024-02-20T17:15:05Z"
4    message: IPPool is ready
5    reason: IPPoolReady
6    status: "True"
7    type: Ready
8  gateway: 192.168.0.1
9  mask: "24"

The Claim Handler

The Claim Handler is the responsible for the real communication between the controller and the IPAM software.

It will be called for every new IPAddress/IPAddressClaim reconciliation

This interface implementation requires that 3 methods are defined:

  • A method called “FetchPool” that will be the first method called, to get the Pool requested from IPAddressClaim and populate the required structures
  • A method called EnsureAddress that is responsible to check or allocate a new address for the claim
  • A method called ReleaseAddress that is responsible to release the address back to the Pool

The handler implementation will look something like:

 1package ipaddress
 2
 3import (
 4	"context"
 5	"fmt"
 6	"strconv"
 7
 8	"github.com/pkg/errors"
 9	"k8s.io/apimachinery/pkg/types"
10	"sigs.k8s.io/cluster-api-ipam-provider-in-cluster/pkg/ipamutil"
11	ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
12	ctrl "sigs.k8s.io/controller-runtime"
13	"sigs.k8s.io/controller-runtime/pkg/client"
14
15	"github.com/rikatz/capi-phpipam/api/v1alpha1"
16	"github.com/rikatz/capi-phpipam/pkg/ipamclient"
17)
18
19// IPAddressClaimHandler reconciles an IPAddress Claim getting the right address from the right pool
20type IPAddressClaimHandler struct {
21	client.Client
22	claim   *ipamv1.IPAddressClaim
23	mask    int
24	gateway string
25	ipamcl  *ipamclient.IPAMClient
26}
27
28var _ ipamutil.ClaimHandler = &IPAddressClaimHandler{}
29
30// FetchPool fetches the PHPIPAM Pool.
31func (h *IPAddressClaimHandler) FetchPool(ctx context.Context) (client.Object, *ctrl.Result, error) {
32
33	var err error
34	phpipampool := &v1alpha1.PHPIPAMIPPool{}
35
36	if err = h.Client.Get(ctx, types.NamespacedName{Namespace: h.claim.Namespace, Name: h.claim.Spec.PoolRef.Name}, phpipampool); err != nil {
37		return nil, nil, errors.Wrap(err, "failed to fetch pool")
38	}
39
40	if phpipampool.Status.Mask == "" || phpipampool.Status.Gateway == "" || !v1alpha1.PoolHasReadyCondition(phpipampool.Status) {
41		return nil, nil, fmt.Errorf("IPPool is not ready yet")
42	}
43
44	h.mask, err = strconv.Atoi(phpipampool.Status.Mask)
45	if err != nil {
46		return nil, nil, fmt.Errorf("pool contains invalid network mask")
47	}
48	h.gateway = phpipampool.Status.Gateway
49    
50    ipamcl, err := ipamclient.SpecToClient(&phpipampool.Spec)
51	if err != nil {
52		return nil, nil, err
53	}
54	h.ipamcl = ipamcl
55	return phpipampool, nil, nil
56}
57
58// EnsureAddress ensures that the IPAddress contains a valid address.
59func (h *IPAddressClaimHandler) EnsureAddress(ctx context.Context, address *ipamv1.IPAddress) (*ctrl.Result, error) {
60	hostname := fmt.Sprintf("%s.%s", h.claim.GetName(), h.claim.GetNamespace())
61	ipv4, err := h.ipamcl.GetAddress(hostname)
62	if err != nil {
63		return nil, errors.Wrap(err, "failed to get an IP Address")
64	}
65
66	address.Spec.Address = ipv4
67	address.Spec.Gateway = h.gateway
68	address.Spec.Prefix = h.mask
69	return nil, nil
70}
71
72// ReleaseAddress releases the ip address.
73func (h *IPAddressClaimHandler) ReleaseAddress(ctx context.Context) (*ctrl.Result, error) {
74	hostname := fmt.Sprintf("%s.%s", h.claim.GetName(), h.claim.GetNamespace())
75	err := h.ipamcl.ReleaseAddress(hostname)
76	return nil, err
77}

The Provider Adapter

The Provider Adapter implements the middle layer between the generic IPAM reconciler and our specific IPAM Handler.

Its function is to, first allow the Generic controller to setup a new manager for the IPAddressClaim and IPAddress and also, to get the proper ClaimHandler.

The Provider Adapter also has some specific needs (like setting a new Index and some common functions) that will not be part of the snippet, and hopefully we can integrate and migrate into the Generic reconciler

 1// PHPIPAMProviderAdapter is used as middle layer for provider integration.
 2type PHPIPAMProviderAdapter struct {
 3	Client     client.Client
 4	IPAMClient *ipamclient.IPAMClient
 5}
 6
 7var _ ipamutil.ProviderAdapter = &PHPIPAMProviderAdapter{}
 8
 9// SetupWithManager sets up the controller with the Manager.
10func (v *PHPIPAMProviderAdapter) SetupWithManager(_ context.Context, b *ctrl.Builder) error {
11	b.
12		For(&ipamv1.IPAddressClaim{}, builder.WithPredicates(
13			ipampredicates.ClaimReferencesPoolKind(metav1.GroupKind{
14				Group: v1alpha1.GroupVersion.Group,
15				Kind:  v1alpha1.PHPIPAMPoolKind,
16			}),
17		)).
18		WithOptions(controller.Options{
19			// To avoid race conditions when allocating IP Addresses, we explicitly set this to 1
20			MaxConcurrentReconciles: 1,
21		}).
22		Watches(
23			&v1alpha1.PHPIPAMIPPool{},
24			handler.EnqueueRequestsFromMapFunc(v.IPPoolToIPClaims()),
25			builder.WithPredicates(resourceTransitionedToUnpaused()),
26		).
27		Owns(&ipamv1.IPAddress{}, builder.WithPredicates(
28			ipampredicates.AddressReferencesPoolKind(metav1.GroupKind{
29				Group: v1alpha1.GroupVersion.Group,
30				Kind:  v1alpha1.PHPIPAMPoolKind,
31			}),
32		))
33	return nil
34}
35
36// ClaimHandlerFor returns a claim handler for a specific claim.
37func (v *PHPIPAMProviderAdapter) ClaimHandlerFor(_ client.Client, claim *ipamv1.IPAddressClaim) ipamutil.ClaimHandler {
38	return &IPAddressClaimHandler{
39		Client: v.Client,
40		claim:  claim,
41	}
42}

As we can see, it is a simple middle layer between a reconciler and the real IPAM logic code.

Testing if it works

Putting all together, and with the proper “main.go” file (look at the repo!), we can test if the IP allocation works

Note Don’t forget to install the Cluster API core CRDs first with:

1kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/cluster-api/main/config/crd/bases/ipam.cluster.x-k8s.io_ipaddressclaims.yaml
2kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/cluster-api/main/config/crd/bases/ipam.cluster.x-k8s.io_ipaddresses.yaml
3kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/cluster-api/main/config/crd/bases/cluster.x-k8s.io_clusters.yaml

With the controller running and able to reach phpIPAM, and with the PHPIPAMIPPool properly defined, we can create a new IPAddressClaim using our pool, and check if an IP Address is allocated:

 1apiVersion: ipam.cluster.x-k8s.io/v1alpha1
 2kind: IPAddressClaim
 3metadata:
 4  name: first-ip
 5  namespace: default
 6spec:
 7  poolRef:
 8    apiGroup: ipam.cluster.x-k8s.io
 9    kind: PHPIPAMIPPool
10    name: my-ipam-test

After we apply this object, the controller should go to phpIPAM and gets us in return an IPAddress with the same name and an IP allocated, which we can later verify on phpIPAM:

 1# kubectl get ipaddress first-ip -o yaml
 2kind: IPAddress
 3metadata:
 4  name: first-ip
 5  namespace: default
 6.....
 7spec:
 8  address: 192.168.0.11 # This was filled by our controller
 9  claimRef:
10    name: first-ip
11  gateway: 192.168.0.1
12  poolRef:
13    apiGroup: ipam.cluster.x-k8s.io
14    kind: PHPIPAMIPPool
15    name: my-ipam-test
16  prefix: 24

And on phpIPAM:

Allocated IPs