Migrate existing Terraform resources from SDK provider (sdkprovider) to Plugin Framework...
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 omission