provider-resources

Par hashicorp · agent-skills

Implémentez des ressources et sources de données pour un provider Terraform à l'aide du Plugin Framework : opérations CRUD, conception de schéma, modificateurs de plan et validateurs, gestion des erreurs not-found, waiters pour les API à cohérence éventuelle, support de l'import, principes de conception des ressources, et couverture obligatoire par des tests d'acceptance. À utiliser lors de l'ajout ou de la modification d'une ressource ou d'une source de données, pour décider si un concept API doit être modélisé en ressource, pour connecter une ressource au client configuré du provider, pour gérer la dérive ou les erreurs resource-not-found, ou pour réviser une implémentation de ressource avant soumission.

npx skills add https://github.com/hashicorp/agent-skills --skill provider-resources

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 asynchrones
  • references/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 MarkdownDescriptiontfplugindocs 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, &notFound) {
    // 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 id redondant (identifiant API réel exposé à la place)
  • [ ] Import implémenté et couvert par une étape ImportStateVerify
  • [ ] Tests _basic, _disappears et 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)

Références

Skills similaires