Comment créer votre propre plugin provider pour Terraform

Create a Terraform plugin provider

Je me suis lancé, pour un projet professionnel à regarder comment fonctionnaient les plugins Terraform afin de pouvoir créer des ressources personnalisées en utilisant une même base d'Infrastructure as Code que ce que nous utilisons déjà pour provisionner des ressources pour des providers open-source comme Kafka.

Cette fois, il s'agit de créer notre propre provider qui va interragir avec une API.

Attention cependant, ce besoin apparaît particulièrement lorsque vous souhaitez créer et mettre à jour des ressources quasi-statiques qui ne nécessiteront une action manuelle de mise à jour via du code et de re-provisioning.

Comment fonctionne un plugin Terraform

How Terraform plugins works

Comme le décrit le schéma ci-dessus, les plugins providers (ou provisioners) communiquent avec le coeur de Terraform via gRPC, mais ceci est abstrait par une librairie simple de compréhension et d'utilisation.

Chaque plugin communique ensuite avec sa librairie client, par exemple le plugin provider Amazon Web Services communique avec l'API AWS, le provider GitHub avec l'API de GitHub, etc... Il conviendra donc de créer un plugin permettant de communiquer, de quelque façon que ce soit avec votre service.

  • Un provider permet de créer, gérer ou mettre à jour des ressources comme des machines virtuelles sur AWS, par exemple,
  • Un provisioner peut être utilisé pour définir des actions spécifiques sur la machine locale ou sur une machine distante pour préparer une ressource : par exemple, exécuter une commande sur une machine virtuelle que vous êtes en train de créer.

L'idée est que chaque provider puisse être installé facilement sur un environnement. Les plugins Terraform sont donc écrit (comme Terraform lui-même) en langage Go ce qui permet de bénéficier d'un simple binaire à installer dans votre répertoire ~/.terraformd/plugins afin de pouvoir commencer à utiliser un plugin.

Comme indiqué sur la page de documentation Providers, le nommage de votre binaire doit être au format terraform-provider-<NAME>_vX.Y.Z, ils sont ainsi clairement identifiés et versionnés.

Avant d'écrire notre code Go, nous pouvons donc déjà écrire le Makefile qui permettra de builder notre binaire pour nos tests et placer dans ce répertoire avec le bon nommage:

build-dev:
    @[ "${version}" ] || ( echo ">> please provide version=vX.Y.Z"; exit 1 )
    go build -o ~/.terraform.d/plugins/terraform-provider-myprovider_${version} .

.PHONY: build-dev

Nous utiliserons donc la syntaxe suivante pour builder notre binaire et le rendre prêt à l'utilisation :

$ make build-dev version=v0.0.1

Écriture du plugin provider

Commençons maintenant à écrire le code de notre provider en créant un fichier main.go qui va instancier le plugin provider que nous allons nommer myprovider et en respectant les interfaces fournies par le SDK de plugin Terraform :

package main

import (
	"github.com/hashicorp/terraform-plugin-sdk/plugin"
	"github.com/eko/terraform-provider-myprovider/internal/myprovider"
)

func main() {
	plugin.Serve(&plugin.ServeOpts{
		ProviderFunc: myprovider.Provider,
	})
}

Définition du provider

Définissons maintenant le package myprovider en créant le fichier internal/myprovider/provider.go :

package myprovider

import (
	"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/terraform"
)

// Provider returns a schema.Provider for my provider
func Provider() terraform.ResourceProvider {
	p := &schema.Provider{
		Schema: map[string]*schema.Schema{
			"url": {
				Type:        schema.TypeString,
				Required:    true,
				DefaultFunc: schema.EnvDefaultFunc("MYPROVIDER_URL", nil),
				Description: "URL of my provider service API.",
			},
		},

		DataSourcesMap: map[string]*schema.Resource{},

		ResourcesMap: map[string]*schema.Resource{
			"myprovider_query": resourceQuery(),
		},
	}

	p.ConfigureFunc = providerConfigure(p)

	return p
}

func providerConfigure(p *schema.Provider) schema.ConfigureFunc {
	return func(d *schema.ResourceData) (interface{}, error) {
		url := d.Get("url").(string),
		client := NewClient(url)

		return client, nil
	}
}

La structure Provider fournie par le SDK nous demande de déclarer les ressources suivantes :

  • Schema : correspondant au schema du block provider, ici nous fournissons l'URL de l'API qui permettra d'accéder à notre service,
  • ResourcesMap : il s'agit des ressources qu'il sera possible de gérer via le provider, ici nous pourrons donc créer, modifier ou supprimer des ressources query,
  • DataSourcesMap : il s'agit des données des ressources que vous pourrez permettre de récupérer, dans cet exemple je n'en ai pas fourni mais la logique est la même que pour les ressources,
  • ConfigureFunc : une fonction permettant d'initialiser le provider, comme on le voit dans l'exemple, on retourne ici une interface{} qui sera simplement notre client HTTP qui permettra à nos ressources de communiquer avec notre API. Ce client sera fourni en argument des ressources.

Pour plus d'informations, je vous invite à jeter un oeil aux différents types disponibles sur cette structure : https://pkg.go.dev/github.com/hashicorp/terraform-plugin-sdk/helper/schema?tab=doc#Provider

Définition d'une ressource

Terraform resource explained

Enfin, créons la fonction permettant de définir les différentes actions disponibles sur notre ressource query :

package persistedqueries

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/davecgh/go-spew/spew"
	"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/helper/validation"
)

func resourceQuery() *schema.Resource {
	return &schema.Resource{
		Create: resourceQueryCreate,
		Read:   resourceQueryRead,
		Update: resourceQueryUpdate,
		Delete: resourceQueryDelete,

		Timeouts: &schema.ResourceTimeout{
			Create: schema.DefaultTimeout(10 * time.Minute),
			Update: schema.DefaultTimeout(10 * time.Minute),
			Delete: schema.DefaultTimeout(10 * time.Minute),
		},

		Schema: map[string]*schema.Schema{
			"query_id": {
				Type:         schema.TypeString,
				Required:     true,
				ValidateFunc: validation.NoZeroValues,
			},

			"payload": {
				Type:         schema.TypeString,
				Required:     true,
				ValidateFunc: validation.NoZeroValues,
			},
		},
	}
}

Là encore, nous suivons le modèle fourni par le SDK sur la structure Resource.

Nous définissions ici plusieurs méthodes qui nous serviront, en fonction de l'état du state, créer, mettre à jour, supprimer ou simplement lire (pour rafraîchir le state) notre ressource query.

Le schema de notre ressource, qui sera donc nommée myprovider_query est composé dans cet exemple des attributs suivants :

  • query_id : un identifiant de query,
  • payload : le payload que nous souhaitons attribuer à cette query.

Notez que tous les deux sont ici de type string mais vous pouvez bien sûr implémenter le type de votre choix. Vous être également libre d'implémenter une validation personnalisée sur chaque attribut de votre ressource.

Afin de faire fonctionner notre ressource, il ne reste plus qu'à déclarer le code des fonctions. Pour alléger cet article, je vais simplement vous montrer la fonction resourceQueryCreate :

func resourceQueryCreate(d *schema.ResourceData, meta interface{}) error {
        client := meta.(*Client)

        ctx, cancel := context.WithCancel(context.Background())
        defer cancel()

        queryId := d.Get("query_id").(string)
        payload := d.Get("payload").(string)

        log.Printf("[INFO] create a myprovider query for identifier '%s'", queryId)
        log.Printf("[TRACE] query id: '%s', payload: %v", queryId, spew.Sdump(payload))

        returnedId, err := client.Create(ctx, queryId, payload)
        if err != nil {
                return fmt.Errorf("error creating a myprovider query: %v", err)
        }

        d.SetId(returnedId)

        log.Printf("[INFO] myprovider query created")

        return resourceQueryRead(d, meta)
}

Comme nous l'avions vu précédemment, nous récupérons en premier lieu le résultat de notre ConfigureFunc, à savoir notre client HTTP.

Ensuite, nous récupérons les attributs de notre ressource et appelons simplement une méthode Create(ctx, queryId, payload) sur notre client HTTP qui s'occupera d'interragir avec l'API du service que gère notre provider.

En cas d'erreur, retournez simplement l'erreur et si tout se passe bien, l'important est de définir via d.SetId() un identifiant unique correspondant à votre ressource.

Cet identifiant sera alors stocké dans le state et, sur les méthodes de création et de mise à jour, vous pouvez simplement retourner nil ou comme moi, enchaîner sur la méthode resourceQueryRead() qui permettra de confirmer que votre ressource a bien été créée.

Utilisons notre provider !

Votre provider est terminé, il ne vous reste plus qu'à l'utiliser, pour cela, installez le comme vu précédemment en exécutant la commande Make make build-dev version=v0.0.1, puis, définissez un fichier main.tf dans un nouveau répertoire.

Vous devriez alors pouvoir utiliser votre provider :

provider "myprovider" {
  url = "http://my-provider-api.svc.local"
}

resource "myprovider_query" "a_sample_query" {
  query_id = "my-sample-query"
  payload = <<-EOT
  {
    "hello": "world"
  }
  EOT
}

Pour fixer votre provider sur une version particulière, ajoutez :

terraform {
  required_providers {
    myprovider = "~> 0.0.1"
  }
}

Conclusion

Avec simplement quelques lignes de code, nous venons de créer un plugin provider pour Terraform.

La simplicité et la souplesse du SDK permet réellement une excellente intégration avec l'outil.

Si vous avez la moindre question sur le sujet, n'hésitez pas à me contacter par mail ou sur Twitter.