Imported from vantage-sh/terraform-provider-vantage (
AGENTS.md). Install upstream withnpx skills add vantage-sh/terraform-provider-vantage. Copyright stays with the author.
Terraform Provider Vantage - Agent Guidelines
This is a Terraform provider for Vantage, a cloud cost management platform. It uses the Terraform Plugin Framework.
Project Structure
vantage/
├── provider.go # Provider definition and configuration
├── client.go # Vantage API client wrapper
├── *_resource.go # Resource implementations (handwritten)
├── *_resource_model.go # Resource model helpers (handwritten)
├── *_resource_test.go # Acceptance tests
├── *_data_source.go # Data source implementations
├── resource_*/ # Generated schema code (DO NOT EDIT)
│ └── *_resource_gen.go
├── datasource_*/ # Generated schema code (DO NOT EDIT)
│ └── *_data_source_gen.go
Key Conventions
Resources Follow This Pattern
- Generated schema in
resource_<name>/<name>_resource_gen.go- auto-generated, do not edit - Resource implementation in
<name>_resource.go- implements CRUD operations - Model helpers in
<name>_resource_model.go- conversion between API and Terraform types - Tests in
<name>_resource_test.go- acceptance tests
Resource Interface Implementation
Resources must implement these interfaces:
var (
_ resource.Resource = (*MyResource)(nil)
_ resource.ResourceWithConfigure = (*MyResource)(nil)
_ resource.ResourceWithImportState = (*MyResource)(nil) // if importable
)
Token vs ID Pattern
All resources use a token field as the primary identifier, with id aliased to token:
data.Token = types.StringValue(out.Payload.Token)
data.Id = types.StringValue(out.Payload.Token)
Error Handling
Use the handleError helper from client.go:
handleError("Create Resource Name", &resp.Diagnostics, err)
API Client
The provider uses vantage-go client library with V1 and V2 API versions:
- Most resources use
r.client.V2for API calls - Authentication is handled via
r.client.Auth
Development
Local Development Setup
- Add dev override in
~/.terraformrc:
provider_installation {
dev_overrides {
"registry.terraform.io/vantage-sh/vantage" = "<PATH TO GO BIN>"
}
direct {}
}
- Build and install:
go install - Set
VANTAGE_API_TOKENenvironment variable
Running Tests
TF_ACC=1 make test
Tests require a valid VANTAGE_API_TOKEN for acceptance tests.
IMPORTANT: Always run tests after making changes. Tests catch bugs like schema mismatches between generated code and implementation.
Code Generation
Always regenerate after pulling changes or before submitting PRs:
make generate # Regenerate schemas from swagger
go generate ./... # Regenerate documentation
The generation pipeline:
- Downloads swagger from
https://api.vantage.sh/v2/swagger.json - Converts to OpenAPI 3.0
- Generates
spec.jsonviatfplugingen-openapi - Generates Go code in
resource_*/anddatasource_*/directories
Warning: Generated files (*_gen.go) can become stale if not regenerated after swagger updates. This has caused bugs where the generated schema didn't match the API (e.g., a list field generated as a single object). Always regenerate and run tests to catch these issues.
Regenerating Documentation
go generate ./...
Testing Conventions
- Test function naming:
TestAccVantage<Resource>_<scenario> - Use
sdkacctest.RandStringFromCharSetfor random test data - Use
acctest.PreCheck(t)inPreCheckfunction - Use
testAccProtoV6ProviderFactoriesfor provider factories - Test configs are helper functions returning HCL strings
- Write tests for list/array fields with multiple items to catch schema bugs where lists are incorrectly generated as single objects
- Use
t.Skip()for tests requiring specific features (e.g., MSP invoicing)
Testing New Fields
When adding a new field to a resource, always write a test that covers:
- Create - Field is set on initial resource creation
- Update - Field can be modified after creation
- No Drift - Use
PlanOnly: true, ExpectNonEmptyPlan: falseto verify the value persists
Example test structure:
func TestAccResource_withNewField(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Step 1: Create with new field
{
Config: testAccResourceConfig("initial_value"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("vantage_resource.test", "new_field", "initial_value"),
),
},
// Step 2: Update the field
{
Config: testAccResourceConfig("updated_value"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("vantage_resource.test", "new_field", "updated_value"),
),
},
// Step 3: Confirm no drift
{
Config: testAccResourceConfig("updated_value"),
PlanOnly: true,
ExpectNonEmptyPlan: false,
},
},
})
}
Why this matters: A common bug is adding a field to the schema and applyPayload (read) but forgetting to add it to toCreate/toUpdate (write). The field appears to work on create, but the value is never actually sent to the API, causing perpetual drift.
Adding New Resources
- Generate schema in
resource_<name>/directory - Create
<name>_resource.gowith CRUD methods - Create
<name>_resource_model.goif complex type conversions needed - Register in
provider.gounderResources()function - Create
<name>_resource_test.gowith acceptance tests - Add example in
examples/resources/vantage_<name>/
Code Style
- Use
types.StringValue(),types.StringPointer()for Terraform types - Check
IsNull()andIsUnknown()before accessing optional values - Use
resp.Diagnostics.Append()for error accumulation - Use
PlanModifierslikestringplanmodifier.UseStateForUnknown()for computed fields
Common Bugs to Avoid
Data Sources Need Separate Models from Resources
Resources and data sources have different schemas, even for the same API entity:
- Resources only expose fields you can configure (create/update)
- Data sources expose all read-only fields returned by the API
If a data source reuses the resource model, you'll get a "Value Conversion Error" when Terraform tries to map API response fields that don't exist in the struct:
Value Conversion Error: Mismatch between struct and object type:
Object defines fields not found in struct: field_a, field_b, field_c
Solution: Create a separate model for data sources that matches the generated schema in datasource_*/:
// Resource model - only configurable fields
type myResourceModel resource_my_resource.MyResourceModel
// Data source model - includes ALL fields from generated schema
type myDataSourceModel struct {
// Include all fields from datasource_my_resource.MyResourceValue
ConfigurableField types.String `tfsdk:"configurable_field"`
ReadOnlyFieldA types.String `tfsdk:"read_only_field_a"` // Not in resource
ReadOnlyFieldB types.Object `tfsdk:"read_only_field_b"` // Not in resource
}
Important for nested objects: The generated schema uses custom types (e.g., CustomFieldsType, MetadataType). When building nested objects, use the generated New*Value functions instead of generic types.ObjectValue:
// WRONG - creates generic ObjectType, causes type mismatch
cfObj, _ := types.ObjectValue(attrTypes, attrValues)
// CORRECT - creates the expected custom type
cfValue, _ := datasource_my_resource.NewCustomFieldsValue(attrTypes, attrValues)
See managed_account_resource_model.go for a complete example of separate resource and data source models.
Missing Fields in toCreate/toUpdate
When adding a new field to a resource, you must wire it in three places:
- Schema (
resource_<name>/<name>_resource_gen.go) - Generated, defines the field - applyPayload - Reads the field from API response into Terraform state
- toCreate/toUpdate - Sends the field value to the API
Forgetting step 3 causes "silent" failures where:
- Terraform accepts the configuration
- The value is never sent to the API
- The API returns null/default
- Next plan shows drift
// In toCreate/toUpdate - don't forget to add new fields!
func (m *myModel) toCreate(ctx context.Context, diags *diag.Diagnostics) *modelsv2.CreateMyResource {
payload := &modelsv2.CreateMyResource{
// ... existing fields ...
}
// Add new optional fields like this:
if !m.NewField.IsNull() && !m.NewField.IsUnknown() {
payload.NewField = m.NewField.ValueString()
}
return payload
}
Array Fields Must Default to Empty Arrays
The Vantage API often requires array fields to be arrays (even if empty), not nil. When a Terraform config doesn't specify an array field, send an empty array:
// WRONG - sends nil, may cause API 500 error
if !m.ArrayField.IsNull() && !m.ArrayField.IsUnknown() {
items := []string{}
m.ArrayField.ElementsAs(ctx, &items, false)
dst.ArrayField = items
}
// CORRECT - defaults to empty array
if !m.ArrayField.IsNull() && !m.ArrayField.IsUnknown() {
items := []string{}
m.ArrayField.ElementsAs(ctx, &items, false)
dst.ArrayField = items
} else {
dst.ArrayField = []string{}
}
This is especially important in toUpdate methods where partial updates might omit optional array fields.