Setup your Kubernetes cluster on OVH with Pulumi

Setup your Kubernetes cluster on OVH with Pulumi

Infrastructure-as-code is not a new subject, but it is often relies on yaml rather than code when you are using tools like Ansible or Terraform. But, when you're a developer, it would be cooler to be able to code your infrastructure rather than describe it. That's where Pulumi can help you ! With Pulumi, you can code in (almost) all your favorite langage your infrastructure and the deployments.

In this 1st article, I'll show how to initialize a Kubernetes managed cluster on OVHcloud with the Goland SDK (because I want to learn Go in the mean time πŸ˜…)

Installation & Init

First, we need to install pulumi CLI:

# Install
brew install pulumi/tap/pulumi

Once it's done, we can initialize our project with the CLI, specifying that we need the Kubernetes SDK in Go.

# Init a project 
pulumi new kubernetes-go

CLI asks a couple of questions regarding the project basic information:

This command will walk you through creating a new Pulumi project.

Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.

project name (fun-with-ovh): hashnode-article
project description (A minimal Kubernetes Go Pulumi program): A simple demo of Kubernetes on OVH with Pulumi
Created project 'hashnode-article'

Please enter your desired stack name.
To create a stack in an organization, use the format <org-name>/<stack-name> (e.g. `acmecorp/dev`).
stack name (dev): yodamad/fun-with-pulumi
Created stack 'fun-with-pulumi'

Installing dependencies...

go: downloading github.com/pulumi/pulumi/sdk/v3 v3.100.0
go: downloading github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.5.6
go: downloading golang.org/x/net v0.19.0
go: downloading golang.org/x/term v0.15.0
go: downloading golang.org/x/sys v0.15.0
go: downloading github.com/pulumi/esc v0.6.2
go: downloading golang.org/x/crypto v0.17.0
go: downloading github.com/go-git/go-git/v5 v5.11.0
go: downloading golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
go: downloading github.com/mattn/go-isatty v0.0.19
go: downloading github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
go: downloading github.com/skeema/knownhosts v1.2.1
go: downloading golang.org/x/tools v0.15.0
go: downloading golang.org/x/mod v0.14.0
go: downloading github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399
Finished installing dependencies

Your new project is ready to go! ✨

To perform an initial deployment, run `pulumi up`

Project is created, Pulumi has generated several files for us:

  • Pulumi.yaml containing the configuration keys

  • main.go containing the basic sample code

  • go.mod / go.sum referencing the dependencies needed by the project

$ ls -lR
total 96
-rw-r--r--  1 admin_local  staff     95 12 jan 11:16 Pulumi.yaml
-rw-r--r--  1 admin_local  staff   4403 12 jan 11:16 go.mod
-rw-r--r--  1 admin_local  staff  29232 12 jan 11:16 go.sum
-rw-r--r--  1 admin_local  staff   1090 12 jan 11:16 main.go

Have a look to Pulumi.yaml, it contains the name of our project, which langage we are using and the small description we put during the init command.

name: hashnode-article
runtime: go
description: A simple demo of Kubernetes on OVH with Pulumi

Also, main.go contains a sample of how to install a deployment into a Kubernetes cluster.

package main

import (
    appsv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apps/v1"
    corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
    metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {

        appLabels := pulumi.StringMap{
            "app": pulumi.String("nginx"),
        }
        deployment, err := appsv1.NewDeployment(ctx, "app-dep", &appsv1.DeploymentArgs{
            Spec: appsv1.DeploymentSpecArgs{
                Selector: &metav1.LabelSelectorArgs{
                    MatchLabels: appLabels,
                },
                Replicas: pulumi.Int(1),
                Template: &corev1.PodTemplateSpecArgs{
                    Metadata: &metav1.ObjectMetaArgs{
                        Labels: appLabels,
                    },
                    Spec: &corev1.PodSpecArgs{
                        Containers: corev1.ContainerArray{
                            corev1.ContainerArgs{
                                Name:  pulumi.String("nginx"),
                                Image: pulumi.String("nginx"),
                            }},
                    },
                },
            },
        })
        if err != nil {
            return err
        }

        ctx.Export("name", deployment.Metadata.Name())

        return nil
    })
}

Pulumi CLI offers many cool commands like the preview one. With this one, you can simulate what would be applied based on your code. Pulumi describes the stack that will be created. A stack in Pulumi, is the root component containing all components that we'll described for our infrastructure.

$ pulumi preview
Previewing update (fun-with-pulumi)

Downloading plugin: 37.50 MiB / 37.50 MiB [=========================] 100.00% 0s
                                                                                [resource plugin kubernetes-4.5.6] installing
     Type                              Name                              Plan
 +   pulumi:pulumi:Stack               hashnode-article-fun-with-pulumi  create
 +   └─ kubernetes:apps/v1:Deployment  app-dep                           create

Outputs:
    name: "app-dep-d870229d"

Resources:
    + 2 to create

Here we can see that Pulumi detects the Kubernetes deployment and as it's not yet deployed, it needs to be create.

Note that if you don't know which template to use to initialize your project, you just need to run pulumi new and the CLI will list you all the templates available so that you can pick the one that suits you well

Create our Kubernetes on OVH

The basic sample is interesting but requires that we already have a Kubernetes cluster initialized. So how to initialize one with Pulumi ?

First, we need to install the OVHcloud official plugin in Pulumi:

# Install OVH plugin
pulumi plugin install resource ovh 0.36.1 --server https://github.com/ovh/pulumi-ovh/releases/download/v0.36.1/

Also, we need to add OVHcloud SDK in our project

github.com/ovh/pulumi-ovh/sdk v0.36.1

We can clean the sample main.go file

package main

import (
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        return nil
    })
}

First, we create a function to check that required variables to connect to OVHcloud are set:

func checkRequirements(ctx *pulumi.Context) {
    ovhVars := []string{os.Getenv(OVH_APPLICATION_SECRET), os.Getenv(OVH_APPLICATION_KEY),
        os.Getenv(OVH_SERVICE_NAME), os.Getenv(OVH_CONSUMER_KEY)}
    if slices.Contains(ovhVars, "") {
        _ = ctx.Log.Error("A mandatory variable is missing, "+
            "check that all these variables are set: "+
            "OVH_APPLICATION_SECRET, OVH_APPLICATION_KEY, OVH_SERVICE_NAME, OVH_CONSUMER_KEY",
            nil)
    }
}

Then, in a specific config.go file, we create all configuration keys and functions to access them

const OVH_APPLICATION_KEY = "OVH_APPLICATION_KEY"
const OVH_APPLICATION_SECRET = "OVH_APPLICATION_SECRET"
const OVH_CONSUMER_KEY = "OVH_CONSUMER_KEY"
const OVH_SERVICE_NAME = "OVH_SERVICE_NAME"
const OVH_GROUP = "ovh"
const ENDPOINT = "endpoint"
const FLAVOR = "flavor"
const REGION = "region"

func ovhConfig(ctx *pulumi.Context, key string) string {
    return getConfig(ctx, OVH_GROUP, key)
}

// Cluster config keys
const CLUSTER_GROUP = "cluster"
const NAME = "name"
const NODEPOOL = "nodepool"
const MIN_NODES = "min_nodes"
const MAX_NODES = "max_nodes"

func clusterConfig(ctx *pulumi.Context, key string) string {
    return getConfig(ctx, CLUSTER_GROUP, key)
}

func getConfig(ctx *pulumi.Context, group string, key string) string {
    return config.Require(ctx, fmt.Sprintf("%s:%s", group, key))
}

Now, we can create our Kubernetes managed cluster:

func initk8s(ctx *pulumi.Context) error {
    // Get ServiceName (ie your account ID in OVH)
    serviceName := os.Getenv(OVH_SERVICE_NAME)

    // Create a new Kubernetes cluster
    myKube, err := cloudproject.NewKube(ctx, 
        clusterConfig(ctx, NAME), 
        &cloudproject.KubeArgs{
            ServiceName: pulumi.String(serviceName), 
            Region:      pulumi.String(clusterConfig(ctx, REGION)),
        })
    if err != nil {
        return err
    }

    // Export kubeconfig file to a secret
    ctx.Export("kubeconfig", pulumi.ToSecret(myKube.Kubeconfig))

    //Create a Node Pool for the cluster
    var minNodes, _ = strconv.Atoi(clusterConfig(ctx, MIN_NODES))
    var maxNodes, _ = strconv.Atoi(clusterConfig(ctx, MAX_NODES))

    _, err = cloudproject.NewKubeNodePool(ctx, 
        clusterConfig(ctx, NODEPOOL), 
        &cloudproject.KubeNodePoolArgs{
            ServiceName:  pulumi.String(serviceName), 
            KubeId:       myKube.ID(), 
            DesiredNodes: pulumi.Int(minNodes), 
            MaxNodes:     pulumi.Int(maxNodes), 
            MinNodes:     pulumi.Int(minNodes), 
            FlavorName:   pulumi.String(clusterConfig(ctx, FLAVOR)),
    })
    if err != nil {
        return err
    }
    return nil
}

In this sample, we introduce a lot of configuration keys to easily adapt to our target infrastructure and make a reusable code. All these keys are referenced in Pulumi.yamlin the config part:

name: hashnode-article
runtime: go
description: A simple demo of Kubernetes on OVH with Pulumi
config:
  # Cluster info
  cluster:flavor: d2-4
  cluster:name: pulumi-cluster
  cluster:nodepool: pulumi-pool
  cluster:min_nodes: 1
  cluster:max_nodes: 2
  cluster:region: GRA9
  # OVH info
  ovh:endpoint: ovh-eu

Now, we can update the main function with our check and init functions:

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {

        checkRequirements(ctx)

        err := initk8s(ctx)
        if err != nil {
            return err
        }
        return nil
    })
}

As we use the Pulumi config module, we need to add it to our Go dependencies:

go get github.com/pulumi/pulumi/sdk/v3/go/pulumi/config@v3.100.0

We are ready to install our 1st stack. Let's check everything:

$ pulumi preview

Previewing update (article-hashnode)

     Type                              Name                              Plan
 +   pulumi:pulumi:Stack               hashnode-article-fun-with-pulumi  create
 +   β”œβ”€ ovh:CloudProject:Kube          pulumi-cluster                    create
 +   └─ ovh:CloudProject:KubeNodePool  pulumi-pool                       create

Outputs:
    kubeconfig: [secret]

Resources:
    + 3 to create

We can see that we find several elements:

  • The stack contains our Kube cluster and the associated NodePool

  • There'll be an output to this : the kubeconfig file to be able to connect to our cluster

We can also check the configuration that will be used:

$ pulumi config
KEY                VALUE
cluster:flavor     d2-4
cluster:max_nodes  2
cluster:min_nodes  1
cluster:name       pulumi-cluster
cluster:nodepool   pulumi-pool
cluster:region     GRA9
ovh:endpoint       ovh-eu

Everything seems ok ! Let's deploy with pulumi up command.

$ pulumi up
Previewing update (fun-with-pulumi)

[..]
Do you want to perform this update? yes
Updating (fun-with-pulumi)

     Type                              Name                              Status
     pulumi:pulumi:Stack               hashnode-article-fun-with-pulumi
 +   β”œβ”€ ovh:CloudProject:Kube          pulumi-cluster                    created (394s)
 +   └─ ovh:CloudProject:KubeNodePool  pulumi-pool                       created (275s)

Outputs:
  + kubeconfig: [secret]
  + nodePoolID: "xxx"

Resources:
    + 3 created

Duration: 11m15s

Everything is ok. Now we need to retrieve our kubeconfig file to be able to connect to our cluster:

$ pulumi stack output kubeconfig --show-secrets > kubeconfig.yaml

Let's check that the kubeconfigis valid

$ export KUBECONFIG=kubeconfig.yaml
$ kubectl get nodes
NAME                              STATUS   ROLES    AGE   VERSION
pulumi-pool-05ddc2c-node-dcadf5   Ready    <none>   11m   v1.28.3

Conclusion

In this 1st article, we can see that it's really easy with Pulumi to create some infrastructure on a dedicated cloud provider with a "real" as-code approach. Then, you can develop your infrastructure and maintain it in your favorite langage, almost all the time.

This is the little only drawback, as Pulumi is quite new, not all langage are available and not all cloud providers are available in all available langage, for instance OVH SDK doesn't exist in Kotlin. But it was not such a big deal as I want to learn Go πŸ˜‡

At the time this article was written:

  • Pulumi CLI : v3.101.1

  • OVH SDK : 0.36.1

Cover generated thanks to BingAI & LeonardoAI

Thanks to OVHcloud for the support to test all these things

Did you find this article valuable?

Support Matthieu Vincent by becoming a sponsor. Any amount is appreciated!

Β