Migrate existing Terraform resources from SDK provider (sdkprovider) to Plugin Framework. Use when converting legacy resources, ensuring state compatibility, or when the user asks to port/migrate existing resources.
Migrate existing SDK-based resources to Plugin Framework while maintaining state compatibility and behavior parity.
Always drive generation, formatting, and linting through Task:
task generate — generates code and runs task fmt automatically at the endtask fmt — formats Go, Terraform, and whitespacetask lint — runs all lintersDo NOT use go run ./generators/..., go generate, gofmt, goimports, golangci-lint, or make for these workflows. See AGENTS.md and Taskfile.dist.yml (run task --list) for the full command surface.
This skill guides migration of resources from:
internal/sdkprovider/ (terraform-plugin-sdk/v2)internal/plugin/ (terraform-plugin-framework) with YAML-generated codeKey Challenge: Preserve exact behavior and state compatibility so users don't experience breaking changes.
Prerequisites: This skill builds on tf-resource-generator. For YAML syntax, adapter API, modifier patterns, custom view overrides, write-only fields, and all implementation details, see that skill.
Use this skill when:
aiven_* resources from SDK to Plugin FrameworkUse tf-resource-generator skill when:
Find the SDK resource:
# Find resource file
find internal/sdkprovider -name "*resource_*.go" | grep -i "resource_name"
# Find data source file
find internal/sdkprovider -name "*datasource_*.go" | grep -i "resource_name"
Read and document:
Check what API operations the SDK resource uses:
# Search for API client calls in the resource
grep -A 5 "client\." internal/sdkprovider/service/resource_name.go
# IMPORTANT: Also check the data source — it may use a different API operation
grep -A 5 "client\." internal/sdkprovider/service/resource_name_data_source.go
Then find corresponding OpenAPI operation IDs. See tf-resource-generator for OpenAPI search patterns.
Determine clientHandler: The clientHandler YAML value is the Go package name under github.com/aiven/go-client-codegen/handler/. Find it by searching for the operation ID in the module cache:
grep -r "OperationID" $(go env GOMODCACHE)/github.com/aiven/go-client-codegen*/handler/
For example, ServiceFlinkCreateApplication lives in handler/flinkapplication/, so clientHandler: flinkapplication.
| SDK Type | YAML Type |
|----------|-----------|
| schema.TypeString | type: string |
| schema.TypeInt | type: integer |
| schema.TypeFloat | type: number |
| schema.TypeBool | type: boolean |
| schema.TypeList | type: arrayOrdered |
| schema.TypeSet | type: array (or arrayOrdered for performance) |
| schema.TypeMap | additionalProperties: {type: string} |
| SDK Attribute | YAML Attribute |
|---------------|----------------|
| Required: true | required: true |
| Optional: true | optional: true |
| Computed: true | computed: true |
| Sensitive: true | sensitive: true |
| ForceNew: true | forceNew: true |
| ConflictsWith: [] | conflictsWith: [] |
| ExactlyOneOf: [] | exactlyOneOf: [] |
SDK Set of objects:
"tags": {
Type: schema.TypeSet,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"key": {Type: schema.TypeString},
"value": {Type: schema.TypeString},
},
},
}
YAML (use arrayOrdered for performance):
schema:
tags:
type: arrayOrdered
items:
type: object
properties:
key:
type: string
value:
type: string
Do not reshape a field to fix a diff. A list stays a list and a set stays a set, which is what keeps an existing state readable.
Nested collections are the one exception, and the generator decides it: a collection that is
read-only all the way down becomes a computed nested attribute, everything else a block.
No YAML key asks for this — Item.RendersAsAttribute derives it.
The Plugin Framework has no computed blocks: Terraform plans a block from the configuration, so
a value stored for a block the configuration doesn't declare either shows up as a permanent
diff or fails the apply with an inconsistent result. An attribute can be computed, and a
read-only collection gives up nothing by losing the block body — it has no field to write in
it. The two are interchangeable for readers: same list(object({...})) in the state, same
expressions (app.application_versions[0].id), so no state upgrade is involved.
An object stays a one-element collection in both forms, as the SDKv2 state holds it. That is
due for removal in v5.0.0, when current_deployment.0.status becomes
current_deployment.status. Never anticipate it in a migration: it breaks every reader.
Caveat — this holds only when nothing inside is settable. An optional: true +
computed: true collection such as the kafka topic config stays a block, because real
configurations write it in block syntax. Marking one nested field optional: true is enough to
turn the whole collection back into a block. Data sources keep blocks too: their result is not
planned against a configuration, so a block holding API values causes no diff there.
For an optional+computed block, drop the values the user did not set instead — see
flattenConfig in internal/plugin/service/kafka/topic/topic.go. Flatten writes back only
the keys the response carries, so delete(dto, name) leaves whatever prior state holds;
d.Set(name, nil) removes it.
Generated example: application_versions in
internal/plugin/service/flink/jarapplication/zz_resource.go.
CRITICAL: The ID format MUST stay the same for state compatibility.
Find the ID format in SDK code:
# Look for ResourceData.SetId calls
grep -A 2 "SetId" internal/sdkprovider/service/resource_name.go
# Look for ID builder functions
grep -B 5 "buildResourceID\|parseResourceID" internal/sdkprovider/service/resource_name.go
Common ID patterns:
projectproject/service_name/database_nameSet in YAML:
idAttributeComposed: [project, service_name, database_name]
Identify in SDK code and map to generator features:
| SDK Pattern | Generator Feature |
|-------------|------------------|
| StateUpgraders | May need version in YAML + state upgrader |
| CustomizeDiff | modifyPlan: true (writes plan values, d.RequiresReplace for replacement) — not planModifier, which runs inside Read |
| Flatten/Expand functions | expandModifier: true / flattenModifier: true |
| DiffSuppressFunc | No equivalent, see Diff Suppression Has No Equivalent |
| Post-Create/Update waiter (poll Read until the resource converges) | refreshStateExists, or stateAttribute with refreshStateDesired/refreshStateFailed, or ResourceOptions.RefreshStateCheck when the value is nested (see tf-resource-generator) |
| Multiple API calls in Update | Custom updateView via init() override |
| Delete waiter (poll until gone/terminal state; conflicts clear as Delete is re-issued) | deleteStateDesired (see tf-resource-generator); avoid a custom deleteView |
| Complex delete (cancel + delete state machine) | Custom deleteView via init() override |
| Data source looks up by alt key (e.g. name) | A second read op with datasourceLookup: true + resultListLookupKeys; add resultIDField: <GoField> when the lookup endpoint differs in shape from the canonical read and should only resolve the id |
| Sensitive field not stored in state | writeOnly: true |
For implementation details of each, see tf-resource-generator skill.
DiffSuppressFunc cannot be ported. Terraform rejects a plan whose value for a configured attribute
differs from the configuration, which is why the Plugin Framework offers no such hook: SDKv2 only got
away with it through its legacy type-system shims. Decide what the suppression was buying:
forceNew: false on the attribute and let the change apply in place. No update view is needed:
the adapter skips a nil Update and refreshes the state, so the new value is simply stored.modifyPlan — but only for computed attributes, since a configured one must keep the
configured value.CustomizeDiff + SetNew on a
ForceNew computed field). Compute the value in modifyPlan and call d.RequiresReplace on it.Whichever applies, the behavior changes for at least some configurations, so call it out in
CHANGELOG.md. internal/plugin/service/flink/jarversion migrated a resource that used both a
DiffSuppressFunc and CustomizeDiff.
Create definitions/aiven_resource_name.yml. For complete YAML syntax reference, see tf-resource-generator skill.
IMPORTANT: Definition files MUST have the aiven_ prefix. The filename becomes the resource name directly.
Focus on migration-specific concerns:
Sensitive field as sensitive: true in YAML (no regressions)task generate
task build
task lint
CRITICAL: Ensure state is compatible between SDK and Plugin Framework versions.
Check schema version in SDK:
grep -A 3 "SchemaVersion" internal/sdkprovider/service/resource_name.go
If SDK has SchemaVersion > 0, you MUST handle state upgrades.
CRITICAL: Test that existing state from SDK version works with Plugin Framework version.
Use acc.BackwardCompatibilitySteps() helper:
func TestAccAivenResource_backwardCompat(t *testing.T) {
resourceName := "aiven_resource_name.test"
projectName := acc.ProjectName()
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acc.TestAccPreCheck(t) },
Steps: acc.BackwardCompatibilitySteps(t, acc.BackwardCompatConfig{
TFConfig: testAccResourceConfig(projectName),
OldProviderVersion: "4.47.0", // Check CHANGELOG.md for latest
Checks: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "project", projectName),
// Add all key attribute checks
),
}),
})
}
Find the latest version:
head -20 CHANGELOG.md
What this test does:
Examples:
internal/plugin/service/mysql/database/database_test.go - Basic backward compatibilityinternal/plugin/service/pg/user/user_test.go - Complex resource with custom update logicCRITICAL: Verify behavior matches SDK resource exactly.
Find SDK tests:
ls internal/sdkprovider/service/*resource_name*_test.go
Ensure Plugin Framework tests cover:
Update (mutable fields that are not all ForceNew), acceptance tests MUST include a Terraform apply that changes at least one updatable attribute after create — i.e. an explicit update step in the test sequence, not only create + destroy. Skip this only when the resource is create/delete-only or every mutable change forces replacement.Before marking migration complete:
sensitive: true in YAMLacc.BackwardCompatibilitySteps()CHANGELOG.md| Issue | Solution |
|-------|----------|
| Sensitive field no longer marked sensitive | Set sensitive: true on the attribute (and nested fields if applicable); match SDK Sensitive: true exactly |
| ID format changed accidentally | Verify idAttributeComposed matches SDK's ID builder |
| Set ordering causes diffs | Use arrayOrdered instead of array |
| Read-only list of objects diffs on every plan | It must render as a computed attribute; check nothing inside it is optional (see Read-Only Nested Collections) |
| Computed field becomes required | Keep as computed: true if API provides it |
| Custom validation lost | Implement in custom modifier or use schema validation |
| State upgrade needed | Implement state upgrader in Plugin Framework |
| DiffSuppressFunc behavior | Not portable — see Diff Suppression Has No Equivalent |
| Field the API accepts but never returns | Fill it in a readView override only when the state has no value, so imports don't keep a null and existing state stays untouched |
| OpenAPI default would plan a change against SDKv2 state | Workaround: dropDefault: true plus useStateForUnknown: true, and send the API default from the create view. Goes away in v5.0.0 with SDKv2 state support |
| Computed value must force replacement | Compute it in modifyPlan and call d.RequiresReplace(name); schema forceNew runs too early to see it |
| Post-create waiter polls a nested attribute | refreshStateExists: true plus ResourceOptions.RefreshStateCheck; stateAttribute only accepts top-level attributes |
| Renamed ID field missing in old state | Use planModifier: true to extract from composite ID |
| Read fails with 404 after migration | Likely a renamed ID field is empty — use planModifier |
| "was null, but now cty.X" error | Add computed: true + useStateForUnknown: true (see generator skill) |
| Field nested differently in API | Use expandModifier and flattenModifier (see generator skill) |
| Multiple update operations needed | Override updateView via init() (see generator skill) |
| Data source lookup key differs | Add a second read operation with datasourceLookup: true + resultListLookupKeys. If the lookup endpoint returns a different shape than the canonical read (e.g. a directory of ids), also set resultIDField: <GoField> (see generator skill) |
# Find SDK resource
find internal/sdkprovider -name "*resource_*.go" | grep -i "name"
# Analyze SDK schema
grep -A 20 "Schema:" internal/sdkprovider/service/resource.go
# Find SDK ID format
grep -A 2 "SetId" internal/sdkprovider/service/resource.go
# Compare implementations
diff internal/sdkprovider/service/resource.go internal/plugin/service/resource/zz_resource.go
# Run backward compatibility test
task test-acc -- -run TestAccAivenResource_backwardCompat
Once all tests pass and state compatibility is verified:
internal/sdkprovider/ and remove provider registrationCHANGELOG.md under the unreleased section:
- Migrate `aiven_resource_name` to the Plugin Framework
- Change `aiven_resource_name`: deprecate `termination_protection` field. Instead, use [prevent_destroy](https://developer.hashicorp.com/terraform/tutorials/state/resource-lifecycle#prevent-resource-deletion)
Do not maintain both versions - this creates maintenance burden and user confusion.
Sensitive in SDK must stay sensitive in Plugin Framework (sensitive: true); never expose secrets in plan or state output by omissionSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer