Guide d'implémentation des ressources Terraform Provider
Vue d'ensemble
Ce guide couvre le développement de ressources et de sources de données Terraform Provider. Les ressources représentent des objets d'infrastructure que Terraform gère via des opérations Create, Read, Update et Delete (CRUD).
Utilisez le Plugin Framework
pour toutes les ressources et sources de données nouvelles. Plugin SDKv2 est destiné à maintenir
les ressources qui y existent déjà ; ne écrivez pas de nouveau code contre lui. Un fournisseur
peut servir les deux en multiplexant
(terraform-plugin-mux),
donc adopter le Framework ne nécessite jamais une refonte complète. Pour déterminer quel mode
un fournisseur existant utilise, consultez go.mod : la présence de terraform-plugin-mux
signifie qu'il sert à la fois du code SDKv2 et Framework ; uniquement
terraform-plugin-sdk/v2 signifie SDKv2 uniquement ; uniquement
terraform-plugin-framework signifie Framework uniquement. Soyez prudent en migrant
les ressources SDKv2 existantes : le Framework distingue les valeurs nulles des valeurs zéro,
donc les migrations naïves changent le comportement pour les utilisateurs existants (utilisez
la compétence provider-framework-migration, si disponible).
Références (charger selon les besoins) :
references/design-principles.md— ce qui doit (et ne doit pas) devenir une ressource ; sémantique des sources de données ; modélisation des relations et des tâches asynchronesreferences/retries-and-waiters.md— cohérence éventuelle, modèles de retry, et structure des fonctions de statut/attente
Structure des fichiers
La plupart des fournisseurs conservent chaque ressource dans un seul package :
internal/provider/
├── provider.go # Schéma Provider + Configure
├── widget_resource.go # Implémentation de la ressource
├── widget_resource_test.go # Tests d'acceptation
├── widget_data_source.go # Source de données (si applicable)
└── widget_data_source_test.go
Les grands fournisseurs multi-services (par ex. terraform-provider-aws) se divisent plutôt
en packages internal/service/<service>/, avec une taxonomie de fichiers idiomatique
à adopter une fois qu'un package grandit : consts.go, find.go (chercheurs),
status.go (fonctions de statut), wait.go (outils d'attente), sweep.go
(nettoyeurs de test), exports_test.go.
La documentation se trouve dans docs/ et est générée avec tfplugindocs :
docs/
├── resources/<name>.md # généré ; modèle optionnel <name>.md.tmpl
└── data-sources/<name>.md
(Les arbres website/docs/r/*.html.markdown écrits à la main existent dans certains fournisseurs plus anciens
et plus grands — suivez la convention du référentiel cible lors de la modification.)
Structure des ressources
Une ressource Framework est une struct contenant le client API, avec des assertions d'interface rendant les comportements implémentés explicites :
var (
_ resource.Resource = &widgetResource{}
_ resource.ResourceWithConfigure = &widgetResource{}
_ resource.ResourceWithImportState = &widgetResource{}
)
func NewWidgetResource() resource.Resource {
return &widgetResource{}
}
type widgetResource struct {
client *examplecloud.Client
}
func (r *widgetResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_widget"
}
// Configure reçoit le client que le fournisseur a construit dans sa propre Configure.
func (r *widgetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return // fournisseur pas encore configuré (par ex. phase de validation)
}
client, ok := req.ProviderData.(*examplecloud.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *examplecloud.Client, got: %T.", req.ProviderData),
)
return
}
r.client = client
}
func (r *widgetResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthBetween(1, 255),
},
},
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
La manière dont la Configure du fournisseur produit ce client — schéma, résolution des identifiants,
validation — est couverte par la compétence provider-configuration (si disponible).
Sur id : SDKv2 requérait un attribut id magique ; le Framework ne le fait pas.
Si l'API a son propre identifiant, exposez-le sous sa vraie signification et n'ajoutez pas
un deuxième id redondant. Conservez id uniquement quand il est l'identifiant de l'API
(comme ci-dessus).
Opérations CRUD
Create
func (r *widgetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data widgetResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
input := &examplecloud.CreateWidgetInput{
Name: data.Name.ValueStringPointer(),
}
output, err := r.client.CreateWidget(ctx, input)
if err != nil {
resp.Diagnostics.AddError(
"Error creating Widget",
fmt.Sprintf("creating Widget (%s): %s", data.Name.ValueString(), err),
)
return
}
data.ID = types.StringPointerValue(output.ID)
// Pour les APIs avec cohérence éventuelle, attendez que la ressource soit utilisable
// avant de retourner — voir references/retries-and-waiters.md.
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Read
Read doit gérer la suppression hors bande en supprimant la ressource de l'état afin que le prochain plan la recréé, plutôt que d'errer indéfiniment :
func (r *widgetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data widgetResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
output, err := findWidgetByID(ctx, r.client, data.ID.ValueString())
if isNotFound(err) {
tflog.Warn(ctx, "Widget not found, removing from state", map[string]any{"id": data.ID.ValueString()})
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError(
"Error reading Widget",
fmt.Sprintf("reading Widget (%s): %s", data.ID.ValueString(), err),
)
return
}
data.Name = types.StringPointerValue(output.Name)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Update
Appelez l'API uniquement pour les attributs qui ont réellement changé ; comparez le plan contre l'état :
func (r *widgetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state widgetResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if !plan.Description.Equal(state.Description) {
input := &examplecloud.UpdateWidgetInput{
ID: plan.ID.ValueStringPointer(),
Description: plan.Description.ValueStringPointer(),
}
if _, err := r.client.UpdateWidget(ctx, input); err != nil {
resp.Diagnostics.AddError(
"Error updating Widget",
fmt.Sprintf("updating Widget (%s): %s", plan.ID.ValueString(), err),
)
return
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
Delete
Traitez « déjà disparu » comme un succès — l'état final souhaité est atteint :
func (r *widgetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data widgetResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
_, err := r.client.DeleteWidget(ctx, &examplecloud.DeleteWidgetInput{
ID: data.ID.ValueStringPointer(),
})
if isNotFound(err) {
return
}
if err != nil {
resp.Diagnostics.AddError(
"Error deleting Widget",
fmt.Sprintf("deleting Widget (%s): %s", data.ID.ValueString(), err),
)
return
}
}
Import
Avec ResourceWithImportState affirmé, le pass-through de l'identifiant est une ligne :
func (r *widgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
Pour les identifiants multi-parties, analysez un ID d'import délimité (généralement séparé par des virgules) et définissez chaque attribut explicitement.
Principes de conception des ressources
Avant d'implémenter, vérifiez la forme de la chose modélisée (traitement complet dans
references/design-principles.md) :
- Une ressource est le plus petit bloc de construction utile ; si l'API offre CRUD pour cela, cela mérite probablement sa propre ressource.
- Une ressource doit parler à une seule API/service — les ressources multi-services cassent les permissions, l'audit et la configuration des points de terminaison.
- Les sources de données sont en lecture seule et sans effets secondaires. Une source de données singulière erreur sur zéro ou plusieurs correspondances ; une source de données plurielle (nom pluriel) retourne zéro ou plus comme collection et n'erreur sur aucun.
- Les politiques/règles attachées, les invocations de tâches longues et les artefacts versionnés méritent généralement leurs propres ressources plutôt que des attributs sur le parent.
- L'état de démarrage/arrêt ou d'activation/désactivation appartient en tant qu'attribut dans la ressource, pas en tant que ressource séparée.
Conception du schéma
Types d'attributs
| Type Terraform | Type Framework | Cas d'usage |
|---|---|---|
string |
schema.StringAttribute |
Noms, identifiants |
number |
schema.Int64Attribute, schema.Float64Attribute |
Comptages, tailles |
bool |
schema.BoolAttribute |
Drapeaux de fonctionnalité |
list |
schema.ListAttribute |
Collections ordonnées |
set |
schema.SetAttribute |
Éléments uniques non ordonnés |
map |
schema.MapAttribute |
Paires clé-valeur |
object |
schema.SingleNestedAttribute |
Configuration imbriquée complexe |
Donnez à chaque attribut une MarkdownDescription — tfplugindocs la publie,
et c'est la documentation utilisateur principale.
Modificateurs de plan
// Forcer le remplacement quand la valeur change
stringplanmodifier.RequiresReplace()
// Conserver une valeur connue pendant le plan au lieu de (connu après application)
stringplanmodifier.UseStateForUnknown()
Validateurs
stringvalidator.LengthBetween(1, 255)
stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9-]+$`), "must be lowercase alphanumeric with hyphens")
stringvalidator.OneOf("small", "medium", "large")
int64validator.Between(1, 100)
listvalidator.SizeAtLeast(1)
Attributs sensibles
"password": schema.StringAttribute{
Required: true,
Sensitive: true,
},
Gestion de l'état
Chercheurs
Centralisez « obtenir une chose ou une non-trouvée typée » dans un chercheur afin que Read, Delete, les outils d'attente et les tests partagent tous la même sémantique de non-trouvée :
func findWidgetByID(ctx context.Context, client *examplecloud.Client, id string) (*examplecloud.Widget, error) {
output, err := client.GetWidget(ctx, &examplecloud.GetWidgetInput{ID: &id})
if err != nil {
var apiErr *examplecloud.NotFoundError
if errors.As(err, &apiErr) {
return nil, &retry.NotFoundError{LastError: err}
}
return nil, fmt.Errorf("getting Widget (%s): %w", id, err)
}
if output == nil || output.Widget == nil {
return nil, &retry.NotFoundError{Message: "empty result"}
}
return output.Widget, nil
}
func isNotFound(err error) bool {
var nfe *retry.NotFoundError
return errors.As(err, &nfe)
}
Attendre les états des ressources
De nombreuses APIs retournent de Create/Delete avant que la ressource soit utilisable/disparue.
Utilisez retry.StateChangeConf (de github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry —
utilisable à partir de fournisseurs Framework), avec une fonction de statut construite
sur le chercheur et les timeouts dans des constantes nommées :
stateConf := &retry.StateChangeConf{
Pending: []string{"CREATING", "PENDING"},
Target: []string{"ACTIVE"},
Refresh: statusWidget(ctx, r.client, id), // un appel du chercheur : (obj, status, err)
Timeout: widgetCreatedTimeout,
}
outputRaw, err := stateConf.WaitForStateContext(ctx)
Les paires de fonctions de statut/attente complètes (créateurs et suppresseurs d'attente,
gestion des états d'échec, retries de non-trouvée après création, modèles de cohérence
éventuelle) sont dans references/retries-and-waiters.md — lisez-le chaque fois que
l'API est asynchrone ou éventuellement cohérente.
Test
Chaque ressource s'accompagne au minimum de :
_basic— créer avec configuration minimale, affirmer les attributs, puis une étape d'import (ImportState: true,ImportStateVerify: true)_disappears— supprimer l'objet hors bande au milieu du test ; le prochain plan doit proposer la recréation, pas une erreur- Tests par attribut — exercer les mises à jour pour chaque argument non trivial
Grammaire de nommage : tests TestAcc{Resource}_{group?}_{description}, aides
testAccCheck{Resource}Exists / testAccCheck{Resource}Destroy, fonctions de config
testAcc{Resource}Config_{description}. Gardez les configs autonomes, aléatoires les
noms réels des ressources et ne codez jamais en dur les valeurs spécifiques à
l'environnement (IDs de compte, zones, versions).
func TestAccWidget_basic(t *testing.T) {
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
resourceName := "examplecloud_widget.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckWidgetDestroy,
Steps: []resource.TestStep{
{
Config: testAccWidgetConfig_basic(rName),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("name"), knownvalue.StringExact(rName)),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("id"), knownvalue.NotNull()),
},
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
},
},
})
}
func testAccWidgetConfig_basic(rName string) string {
return fmt.Sprintf(`
resource "examplecloud_widget" "test" {
name = %[1]q
}
`, rName)
}
Utilisez la compétence provider-test-patterns (si disponible) pour le traitement
complet des tests : style d'aide de config (verbes indexés %[1]q), statecheck/plancheck,
CompareValue, implémentations StateCheck personnalisées pour les aides exists/disappears,
nettoyeurs et tests de ressources éphémères. Utilisez la compétence run-acceptance-tests
pour exécuter et déboguer les exécutions de tests.
Gestion des erreurs
Faites correspondre les erreurs API par type, pas par texte de message, et enveloppez avec contexte :
var notFound *examplecloud.NotFoundError
if errors.As(err, ¬Found) {
// la ressource n'existe pas
}
// Envelopper dans les aides : préserver la cause avec %w
return fmt.Errorf("creating Widget (%s): %w", name, err)
Les diagnostiques suivent une grammaire cohérente — le résumé nomme l'opération et le type, le détail porte l'identifiant et la cause :
resp.Diagnostics.AddError(
"Error creating Widget",
fmt.Sprintf("creating Widget (%s): %s", name, err),
)
resp.Diagnostics.AddAttributeError(
path.Root("name"),
"Invalid name",
"Name must be lowercase alphanumeric",
)
Documentation
Écrivez les MarkdownDescriptions des attributs en premier — ce sont la source de
vérité. Ensuite, générez la documentation Registry avec tfplugindocs
(go generate ./... où configuré), en ajoutant des modèles docs/**/*.md.tmpl
uniquement pour la prose et les exemples que le générateur ne peut pas dériver.
Utilisez la compétence provider-docs (si disponible) pour le flux de documentation
complet et les règles de publication du Registry.
Liste de contrôle avant soumission
- [ ] Plugin Framework utilisé (pas de nouveau code SDKv2)
- [ ] La ressource a toutes les opérations CRUD implémentées
- [ ] Read supprime les ressources manquantes de l'état ; Delete tolère déjà-supprimé
- [ ] Pas d'attribut
idredondant (identifiant API réel exposé à la place) - [ ] Import implémenté et couvert par une étape
ImportStateVerify - [ ] Tests
_basic,_disappearset par attribut présents - [ ] Outils d'attente utilisés où l'API a une cohérence éventuelle
- [ ] Les messages d'erreur nomment l'opération, le type et l'identifiant
- [ ] Attributs sensibles marqués ; chaque attribut a une description
- [ ] Docs générée avec
tfplugindocs - [ ] Entrée de changelog ajoutée, si le repo suit les notes de version (vérifiez CONTRIBUTING)