Terraform, provider personnalisé ou comment consommer de l’API autrement
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
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
Mais à quoi bon un provider custom ?
J’en veux un, comment je fais ?
On code !! 👨💻👩💻
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 120mNous 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 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…

