03 62 26 44 59 contact@ekit3.com

      Terraform custom provider

      par | Nov 20, 2024 | Tech

      Terraform, provider personnalisé ou comment consommer de l’API autrement

      Terraform est devenu un outil incontournable dans le monde du DevOps, plus particulièrement dans l’univers de l’infrastructure as code.
      Dans cet article, nous mettrons en évidence une autre manière de penser l’utilisation de Terraform, le « service as code ». On oublie souvent que Terraform fonctionne grâce aux différentes APIs fournies par des tiers. Mais comment tout cela fonctionne ? C’est ce que nous verrons ici. À la fin de cet article, vous aurez développé un provider Terraform simple et connecté à une API basique.

      Pour mieux appréhender cet article, il est préférable d’être déjà initié à Terraform et de manière plus générale d’avoir une connaissance de l’infrastructure as code. Ne vous inquiétez pas, même si vous n’êtes pas familier avec ces concepts, nous commencerons tout de même par des petits rappels, qui vous permettront de suivre ce guide pas à pas.

       

      Petits rappels

      Terraform

      Terraform est un outil qui permets de créer, mettre à jour et versionner vos infrastructures de manière sécurisée et efficiente. Une infrastructure composée de ressources cloud, comme des bases de données, des machines virtuelles ou n’importe quel service de votre fournisseur cloud préféré.

      Il peut aussi déployer des ressources « maison », via du développement custom, qui seront propres à vos besoins et c’est justement tout l’objet de cet article !

       

      Terraform provider

      Les providers Terraform vous permettront d’intéragir avec un service tiers (ex : GCP, GitHub, etc…). On pourrait comparer les providers Terraform avec des librairies dans les langages de développement. Ces interactions seront réalisées via les différentes ressources fournies par ces providers, par exemple le provider Terraform de GitHub fournit une ressource « github_team » qui permet de créer, modifier ou supprimer une team GitHub.
      Si vous n’avez jamais manipulé Terraform, je vous conseille d’aller voir ce guide dans un premier temps avant de continuer celui-ci.

      Mais à quoi bon un provider custom ?

      Comme je l’ai dit en introduction, on peut voir Terraform comme une autre manière de consommer de l’API. Dans la plupart des cas, quand on veut consommer une API, que ce soit pour de la lecture ou de l’écriture, on développera soit une autre API, soit un batch. Ce qui demande déjà (selon les besoins) une certaine expertise et un certain nombre de resources (temps, matériels, humains). Cela peut vite devenir un frein à la consommation de vos services.
      Avec un provider propre à vos services, vous offrez la possibilité à vos utilisateurs de consommer directement et simplement votre ou vos APIs, le code Terraform étant très descriptif.
      En plus de cela vous bénéficiez des autres avantages de Terraform, l’état de votre utilisation des services correspond à votre code. La persistance de l’état des objets créés plus tôt est pratique dans les cas de DRP par exemple.

      J’en veux un, comment je fais ?

      Ici nous réaliserons un guide pas à pas, pour développer ce fameux provider custom. Pour cela dans le repository git, il y a un fichier docker-compose.yml qui vous permettra de lancer une base de données, une API et une application web pour visualiser le contenu de cette base de données.
      Cette API n’est qu’un simple CRUD sur un objet « Cour », qui possède plusieurs champs : un nom, une durée et une description.

      Prérequis

      Terraform CLI
      Docker Compose
      Golang

       

      On code !! 👨‍💻👩‍💻

      Hashicorp fournit différentes librairies pour faciliter le développement de providers Terraform, il fournit également des repositories d’exemples qui permettent de commencer rapidement le développement d’un provider et tout un ensemble de guides très complets.

      Pas à pas

      Dans un premier temps, on se crée un petit répertoire tout propre, et on y clone le projet

      mkdir demo-custom-provider && cd demo-custom-provider
      git clone git@github.com:ekit3/terraform-custom-provider.git

      Vous retrouvez dans ce repo différentes choses,

      • un dossier « custom-provider »: qui contient le résultat final du provider que l’on développera dans le dossier « final-code », mais aussi un boilerplate pour vous permettre de commencer le guide, dans le dossier ‘ressource’
      • un dossier « docs »: qui contient le code du provider et des exemples Terraform pour chaque étape que l’on réalisera, en quelque sorte : les solutions
      • et pour finir, une API et un front qui vont de pair avec le docker-compose.

      Le front ne sert qu’à visualiser les actions, qui seront exécutées par le provider Terraform, même si on pourrait se contenter des résultats des commandes Terraform. Mais voir le résultat sur un front vous fera prendre conscience que vous pouvez bien créer n’importe quelle ressource. L’API quant à elle est OBLIGATOIRE, vu qu’un provider Terraform fonctionne forcément avec une API. N’hésitez pas à utiliser une de vos propres APIs, et à adapter les bouts de code qui suivront celle ci.

      Dans un premier temps, placez-vous dans le dossier, « custom-provider/resources/terraform-provider-courses », il s’agit du boilerplate de départ.

      Explication du contenu de ce dossier.
      Le dossier « courses » contient le premier fichier de code go pour notre futur provider. Celui-ci initialise un provider, mais vide !

      package courses
      
      import (
          "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
      )
      
      // Provider -
      func Provider() *schema.Provider {
          return &schema.Provider{
              ResourcesMap:   map[string]*schema.Resource{},
              DataSourcesMap: map[string]*schema.Resource{},
          }
      }
      

      Le fichier « main.go », est le point d’entrée de notre provider.

      package main
      
      import (
          "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
          "github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
      
          "terraform-provider-courses/courses"
      )
      
      func main() {
          plugin.Serve(&plugin.ServeOpts{
              ProviderFunc: func() *schema.Provider {
                  return courses.Provider()
              },
          })
      }

      Le dossier « terraform », contient le code Terraform qui appelera notre custom provider.

      terraform {
        required_providers {
          courses = {
            version = "0.1"
            source  = "hashicorp.com/ekite/courses"
          }
        }
      }
      
      provider "courses" {}

      Le « Makefile », qui lui nous permettra de build et installer notre provider sur notre machine. Vous remarquerez que les informations décrites dans ce fichier correspondent à l’appel du provider, dans le fichier ci-dessus.

      TEST?=$$(go list ./... | grep -v 'vendor')
      HOSTNAME=hashicorp.com
      NAMESPACE=ekite
      NAME=courses
      BINARY=terraform-provider-${NAME}
      VERSION=0.1
      OS_ARCH=darwin_amd64
      
      default: install
      
      build:
          go build -o ${BINARY}
      
      release:
          GOOS=darwin GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_darwin_amd64
          GOOS=freebsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_freebsd_386
          GOOS=freebsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_freebsd_amd64
          GOOS=freebsd GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_freebsd_arm
          GOOS=linux GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_linux_386
          GOOS=linux GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_linux_amd64
          GOOS=linux GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_linux_arm
          GOOS=openbsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_openbsd_386
          GOOS=openbsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_openbsd_amd64
          GOOS=solaris GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_solaris_amd64
          GOOS=windows GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_windows_386
          GOOS=windows GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_windows_amd64
      
      install: build
          mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}
          mv ${BINARY} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}
      
      test: 
          go test -i $(TEST) || exit 1                                                   
          echo $(TEST) | xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4                    
      
      testacc: 
          TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 120m

      Nous allons maintenant build cette première version de notre provider, pour ce faire placez vous dans le dossier « custom-provider/resources/terraform-provider-courses »

      On initialise notre module go. Le nom choisi ici doit matcher le nom choisi dans le ‘main.go’ au niveau des imports.

      go mod init terraform-provider-courses

      On installe les dépendances, de notre module

      go mod tidy

      Après cela, nous pouvons utiliser notre Makefile pour build et installer notre module

      make build && make install

      Vous pouvez ouvrir une autre console et vous placer dans le dossier « terraform ». Nous allons vérifier le build et l’install de ce provider.

      terraform init
      terraform plan

      Nous avons donc notre custom provider configuré et fonctionnel, même si pour le moment il ne fait pas grand chose !

      Nous allons maintenant entrer dans le vif du sujet, mais allons-y étape par étape. Nous commençons par développer une première « data » Terraform qui correspondra à l’ensemble des cours renvoyés par l’API. Pour rappel Terraform a deux types d’objets qu’on manipule à travers les providers:

      • les ressources, qui correspondent aux objets que vous créez. Par exemple, une adresse IP ou un repository GitHub.
      • les datas, qui correspondent à des informations renvoyées par un provider. Par exemple, une data qui contiendra la liste des membres d’une équipe GitHub.

      Commençons par lancer notre docker-compose.

      docker-compose up -d
      Ces routes sont disponibles dans l'api,
      GET   http://localhost:4000/cours, renvoie une liste de cours
      POST http://localhost:4000/cours, crée un cour avec ce body 
      {
          "name": "Petit intervention",
          "time": 60,
          "summary": "intervention sur sujet tech"
      }
      PUT http://localhost:4000/cours/651fc2f3970d6264792bf49e, avec le même body que pour le POST
      DELETE http://localhost:4000/cours/65201115d9a28e309a48d610, qui supprime le cours.

      Vous pouvez maintenant copier coller les fichiers du dossier « docs/providers/step1 », dans le dossier « custom-provider/resources/terraform-provider-courses/courses »

      Le fichier, « data_source_courses.go », contient la définition de notre data source pour les cours.
      Il existe deux méthodes importantes. « dataSourceCourses », qui est le point d’entré pour terraform pour comprendre comment interagir avec cette « data ». Dans cette méthode vous avez deux options, « ReadContext », qui prend en valeur la fonction qui sera exécutée pour renvoyer la valeur de cette data, et « Schema », qui sert à définir le schéma de la donnée renvoyée par le provider.
      Dans ce même fichier, vous avez la méthode « dataSourceCoursesRead », qui sera appelée pour récupérer la data. Dans celle-ci vous remarquerez que ce n’est qu’un bout de code qui fait un appel à notre API (cf ligne 57). Autre ligne intéressante, la ligne 79. Sur celle-ci, nous voyons que nous associons un id à notre data, primordial à Terraform ! Je vous invite maintenant à builder la version 0.2 de votre provider (n’oubliez pas de monter la version dans le Makefile !)
      Vous trouverez également les fichiers Terraform pour tester cette nouvelle version. Utilisez les fichiers dans le dossier docs, example1.tf. Vous pouvez créer un ou plusieurs cours via l’API. Utilisez les commandes suivantes pour installer la nouvelle version du module et lancer le plan Terraform. Vous pouvez l’apply si vous le souhaitez. (/!\ pour la suite, si vous appliquez des objects entre différentes versions du provider ou si vous relancez le docker compose, n’oubliez pas de clean vos states Terraform, pour repartir de zéro)

      terraform init --upgrade
      terraform plan

      Maintenant nous nous attaquons à la création de ressources! Nous commençons par la création d’un cours!
      Comme pour l’étape précédente, vous pouvez prendre les fichiers dans les ressources step2 et example2!

      Comme vous pouvez le constater le main.tf ne va pas changer, seul le provider.tf va subir une modification. En plus de la partie data, nous rajoutons maintenant notre ressource « ekite_cour », qui va nous permettre de créer, récupérer, mettre à jour ou supprimer un cours dans notre API.
      C’est la méthode dataSourceCour(), qui va contenir toutes les méthodes nécessaires aux actions d’une ressource Terraform, celle-ci implémentant les méthodes fournies par Hashicorp.
      On peut voir que la méthode DataSourceCour(), renvoie un objet *schema.Resource qui prend en paramètre des méthodes de « Contexte », qui implémentent le code qui va être lié aux actions sur la ressource. C’est pour cela qu’on en retrouve une pour Create Read Update Delete.
      Dans ces méthodes, je vais seulement implémenter des calls à mon API, et le code nécessaire à Terraform pour garantir la consistance des ressources maintenues par ce provider. Ce code n’étant que le set d’un id à la ressource Terrafom, qui lui permet de l’identifier dans son backend Terraform.
      On peut voir que j’utilise la variable « d » qui correspond à la ressource qu’on est en train de manipuler. Je peux récupérer les différentes valeurs poussées dans la définition de la ressource.

      package courses
      
      import (
          "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
      )
      
      // Provider -
      func Provider() *schema.Provider {
          return &schema.Provider{
              ResourcesMap: map[string]*schema.Resource{
                  "ekite_cour": dataSourceCour(),
              },
              DataSourcesMap: map[string]*schema.Resource{
                  "ekite_courses": dataSourceCourses(),
              },
          }
      }
      
      package courses
      
      import (
          "bytes"
          "context"
          "encoding/json"
          "log"
          "net/http"
      
          "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
      
          "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
      )
      
      type Cour struct {
          Id      string `json:"_id"`
          Name    string `json:"name"`
          Time    int    `json:"time"`
          Summary string `json:"summary"`
      }
      
      func dataSourceCour() *schema.Resource {
          return &schema.Resource{
              CreateContext: resourceCourCreate,
              ReadContext:   resourceCourRead,
              UpdateContext: resourceCourUpdate,
              DeleteContext: resourceCourDelete,
      
              Schema: map[string]*schema.Schema{
                  "_id": &schema.Schema{
                      Type:     schema.TypeString,
                      Optional: true,
                  },
                  "name": &schema.Schema{
                      Type:     schema.TypeString,
                      Required: true,
                  },
                  "time": &schema.Schema{
                      Type:     schema.TypeInt,
                      Required: true,
                  },
                  "summary": &schema.Schema{
                      Type:     schema.TypeString,
                      Required: true,
                  },
              },
          }
      }
      
      func resourceCourCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
      
          var diags diag.Diagnostics
      
          name := d.Get("name").(string)
          time := d.Get("time").(int)
          summary := d.Get("summary").(string)
      
          data := map[string]interface{}{
              "name":    name,
              "time":    time,
              "summary": summary,
          }
      
          jsonData, err := json.Marshal(data)
      
          resp, err := http.Post("http://localhost:4000/cours", "application/json", bytes.NewBuffer(jsonData))
      
          if err != nil {
              log.Fatal(err)
          }
          defer resp.Body.Close()
          cour := &Cour{}
          err = json.NewDecoder(resp.Body).Decode(&cour)
          if err != nil {
              return diag.FromErr(err)
          }
          // THIS IS MANDATORY FOR TERRAFORM TO BE ABLE TO GARANT CONSISTENCY OF YOUR RESOURCES
          d.SetId(cour.Id)
          return diags
      }
      
      func resourceCourRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
      
          return nil
      }
      
      func resourceCourUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
      
          return nil
      }
      
      func resourceCourDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
      
          return nil
      }
      

       

      Vu que dans cette étape nous n’implémentons que la méthode de création, si vous vous amusez à éditer ou supprimer la ressource, rien ne se passera, même pas une erreur ! Par contre, vos states Terraform seront complètement corrompus. Si c’est le cas n’hésitez pas à tout supprimer et réinitialiser votre workspace Terraform. Pour créer la ressource, comme d’habitude, montez la version, réinstallez votre provider local, votre petit « terraform init –upgrade » et prenez le contenu du fichier exemple2.tf. Vous venez de mettre en place la création d’une ressource par Terraform.

      terraform {
        required_providers {
          courses = {
            version = "0.0.2"
            source  = "hashicorp.com/ekite/courses"
          }
        }
      }
      
      resource "ekite_cour" "mon_cour" {
        provider = courses
        name = "Cour créé depuis terraform"
        time = 60
        summary = "Un simple exemple"
      }
      

      Maintenant vous pouvez soit aller directement à l’étape 4, qui finit par implémenter la suppression de ressource, soit passer par la 3 qui commence par implémenter les méthodes de lecture et de mise à jour. Dans mon cas je vais aller à l’étape 4 et expliquer le code final qui comprend les deux.
      Donc maintenant je voudrais pouvoir éditer mes ressources Terraform, et pour ce faire je pensais qu’implémenter la méthode « resourceCourUpdate » suffirait. Mais non, cela dépend de la méthode « read », puisque Terraform, avant de mettre à jour votre ressource, va aller la lire dans votre système d’information.
      La méthode de lecture a été la plus simple a implémenter, dans le sens où il n’y pas de spécificité propre à Terraform, comme on la vu précédemment avec le set de l’id dans la méthode de create. En revanche, dans la méthode d’update, nous retrouvons une instruction spécifique à Terraform, « d.Set(« last_updated », time.Now().Format(time.RFC850)) ». Celle-ci est indispensable à Terraform. Encore une fois pour garantir la persistance et la cohérence de vos ressources Terraform. Du côté du delete, on retrouve la même idée que pour le create, mais ici nous allons juste supprimer l’id de notre ressource.

      package courses
      
      import (
          "bytes"
          "context"
          "encoding/json"
          "log"
          "net/http"
          "time"
      
          "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
      
          "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
      )
      
      type Cour struct {
          Id      string `json:"_id"`
          Name    string `json:"name"`
          Time    int    `json:"time"`
          Summary string `json:"summary"`
      }
      
      func dataSourceCour() *schema.Resource {
          return &schema.Resource{
              CreateContext: resourceCourCreate,
              ReadContext:   resourceCourRead,
              UpdateContext: resourceCourUpdate,
              DeleteContext: resourceCourDelete,
      
              Schema: map[string]*schema.Schema{
                  "_id": &schema.Schema{
                      Type:     schema.TypeString,
                      Optional: true,
                  },
                  "name": &schema.Schema{
                      Type:     schema.TypeString,
                      Required: true,
                  },
                  "time": &schema.Schema{
                      Type:     schema.TypeInt,
                      Required: true,
                  },
                  "summary": &schema.Schema{
                      Type:     schema.TypeString,
                      Required: true,
                  },
                  "last_updated": &schema.Schema{
                      Type:     schema.TypeString,
                      Optional: true,
                      Computed: true,
                  },
              },
          }
      }
      
      func resourceCourCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
      
          var diags diag.Diagnostics
      
          name := d.Get("name").(string)
          time := d.Get("time").(int)
          summary := d.Get("summary").(string)
      
          data := map[string]interface{}{
              "name":    name,
              "time":    time,
              "summary": summary,
          }
      
          jsonData, err := json.Marshal(data)
      
          resp, err := http.Post("http://localhost:4000/cours", "application/json", bytes.NewBuffer(jsonData))
      
          if err != nil {
              log.Fatal(err)
          }
          defer resp.Body.Close()
          cour := &Cour{}
          err = json.NewDecoder(resp.Body).Decode(&cour)
          if err != nil {
              return diag.FromErr(err)
          }
          // THIS IS MANDATORY FOR TERRAFORM TO BE ABLE TO GARANT CONSISTENCY OF YOUR RESOURCES
          d.SetId(cour.Id)
          return diags
      }
      
      func resourceCourRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
          var diags diag.Diagnostics
          client := &http.Client{Timeout: 10 * time.Second}
      
          url := "http://localhost:4000/cours/" + d.Id()
      
          req, err := http.NewRequest("GET", url, nil)
          if err != nil {
              return diag.FromErr(err)
          }
      
          r, err := client.Do(req)
          if err != nil {
              return diag.FromErr(err)
          }
          defer r.Body.Close()
          cour := &Cour{}
          err = json.NewDecoder(r.Body).Decode(&cour)
          if err != nil {
              return diag.FromErr(err)
          }
          d.Set("name", cour.Name)
          d.Set("time", cour.Time)
          d.Set("summary", cour.Summary)
      
          return diags
      }
      
      func resourceCourUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
          client := &http.Client{Timeout: 10 * time.Second}
          name := d.Get("name").(string)
          timeobj := d.Get("time").(int)
          summary := d.Get("summary").(string)
      
          data := map[string]interface{}{
              "name":    name,
              "time":    timeobj,
              "summary": summary,
          }
      
          jsonData, err := json.Marshal(data)
      
          url := "http://localhost:4000/cours/" + d.Id()
      
          req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(jsonData))
          if err != nil {
              log.Fatal(err)
          }
          req.Header.Set("Content-Type", "application/json; charset=utf-8")
          resp, err := client.Do(req)
          if err != nil {
              panic(err)
          }
          defer resp.Body.Close()
          // THIS IS MANDATORY FOR TERRAFORM TO BE ABLE TO GARANT CONSISTENCY OF YOUR RESOURCES
          d.Set("last_updated", time.Now().Format(time.RFC850))
          return resourceCourRead(ctx, d, m)
      }
      
      func resourceCourDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
          var diags diag.Diagnostics
          client := &http.Client{}
          url := "http://localhost:4000/cours/" + d.Id()
          req, err := http.NewRequest("DELETE", url, nil)
          if err != nil {
              panic(err)
          }
          resp, err := client.Do(req)
          if err != nil {
              panic(err)
          }
          defer resp.Body.Close()
          // THIS IS MANDATORY FOR TERRAFORM TO BE ABLE TO GARANT CONSISTENCY OF YOUR RESOURCES
          d.SetId("")
          return diags
      }
      

      Une fois que vous avez mis à jour votre code, même chose, on rebuild on réinstalle et on réinitialise et vous pouvez vous amuser avec votre provider.

      Si vous voulez un résumé vous pouvez trouver le déroulé de ce pas à pas ici: https://www.youtube.com/watch?v=hVoPL61x5dI

      Conseils et conclusion

      Nous venons, en quelques minutes, de donner la possibilité de consommer un service, et le tout as code, je vous laisse imaginer les possibilités qui s’ouvrent à vous.

      Nous permettrons ainsi à nos utilisateurs de consommer notre service de manière très simple, via le code déclaratif de Terraform, pouvant automatiser tout son processus de validation Terraform via des CI les plus utilisés. Ceci pourrait vous débloquer dans la mise en place de vos processus DevOps 😉

      Dans un futur article, nous verrons comment configurer nos custom providers…

      Auteur

      En savoir plus sur EKITE

      Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

      Poursuivre la lecture