This file provides guidance to Claude Code when working with this Terraform provider.
- Build:
make build| Test + Lint:make dev| Pre-commit:make precommit - Breaking changes: Avoid for released resources; use deprecation for field renames (see Breaking Changes Policy)
- Resource packages in
internal/<resource>/with_resource.go,_data_source.go,*_test.go,util.go - Examples & tests in
examples/<resource>/withmain.tfandtests/*.tftest.hcl - ID fields: Always suffix with
ID(e.g.,ProjectID,InstanceTemplateID) - API errors: Use
common.UnpackAPIError(err), noterr.Error() - State extraction: Use the shared generic
common.GetResourceModel()helper - Schema validators: Add
stringvalidator/int64validatordirectly in schema - Testing validators: Go unit tests (not Terraform
expect_failures) for schema validators - Deterministic lists: Sort API-ordered collections before setting state (
common.SortByKeys; key priorityname → updated_at → created_at → id) - Reference implementation:
internal/instance_group/follows all current patterns
- CLAUDE.md
This is the Crusoe Cloud Terraform Provider, enabling infrastructure-as-code management of Crusoe Cloud resources.
make build # Build the provider
make dev # Run tests + lint
make test # Run tests only
make lint # Run golangci-lint only
make precommit # Run tests + lint with auto-fix
make docs # Generate documentationinternal/
├── common/ # Shared utilities (API client, helpers, validators)
├── <resource>/ # Resource packages (vm, disk, vpc_network, etc.)
│ ├── <resource>_resource.go # CRUD operations
│ ├── <resource>_resource_test.go # Resource schema and mapping tests
│ ├── <resource>_data_source.go # Read-only data source
│ ├── <resource>_data_source_test.go # Data source schema and mapping tests
│ ├── <resource>_resource_upgrade.go # State migrations (if needed)
│ ├── util.go # Package-specific helpers, shared descriptions
│ └── util_test.go # Schema consistency tests
examples/
├── <resource>/
│ ├── main.tf # Example configuration
│ └── tests/
│ ├── unit.tftest.hcl # Plan-only validation tests
│ └── integration.tftest.hcl # Apply/destroy tests
Avoid breaking changes for released resources. Most resources in this provider are publicly released and used by customers. Breaking their Terraform configurations on upgrade causes significant disruption.
-
Field Renames: Use deprecation, not removal
// Old field - mark deprecated but keep functional "instance_template": schema.StringAttribute{ Optional: true, Computed: true, DeprecationMessage: common.FormatDeprecationWithReplacement("v0.6.0", "instance_template_id"), }, // New field - add alongside old field "instance_template_id": schema.StringAttribute{ Optional: true, Computed: true, },
-
Field Removal: Only after deprecation period (minimum one minor version)
-
Behavior Changes: Must be backwards compatible or behind a new field
Once all resources have been migrated to new patterns with proper deprecations:
- Announce deprecation timeline to customers
- Release major version (v1.0.0 or v2.0.0) that removes deprecated fields
- Document migration guide for customers
Note: The patterns below are recommended standards being rolled out. They were first applied to
internal/instance_group/. Other packages may still use older patterns and should be updated incrementally with deprecations (see Breaking Changes Policy above).
Each resource package follows this pattern:
- Resource struct with
*common.CrusoeClient - Model struct with
types.String,types.Int64,types.Listfields usingtfsdktags - Schema method defining attributes with
schema.StringAttribute, etc. - CRUD methods:
Create,Read,Update,Delete
For ID fields, always include ID as a suffix in both the struct field name and the tfsdk tag:
// Good
InstanceTemplateID types.String `tfsdk:"instance_template_id"`
ProjectID types.String `tfsdk:"project_id"`
// Bad
InstanceTemplate types.String `tfsdk:"instance_template"`
Template types.String `tfsdk:"template_id"`Use the shared generic common.GetResourceModel() helper to extract state, plan, or
config with error handling. It lives in internal/common/resource_model.go — do not
add a per-package copy. The model type is inferred from dest, so callers never specify
the type parameter:
// internal/common/resource_model.go (shared, generic — already defined)
var ErrGetResourceModel = errors.New("unable to get resource model")
// TFDataGetter is implemented by tfsdk.State, tfsdk.Plan, and tfsdk.Config.
type TFDataGetter interface {
Get(ctx context.Context, target interface{}) diag.Diagnostics
}
func GetResourceModel[T any](ctx context.Context, source TFDataGetter, dest *T, respDiags *diag.Diagnostics) error {
diags := source.Get(ctx, dest)
respDiags.Append(diags...)
if respDiags.HasError() {
return ErrGetResourceModel
}
return nil
}
// Usage in a resource's CRUD methods:
var state myResourceModel
if err := common.GetResourceModel(ctx, req.State, &state, &resp.Diagnostics); err != nil {
return
}Always use common.UnpackAPIError(err) for API errors (not err.Error()).
Source of truth: Attribute descriptions come from the github.com/crusoecloud/client-go swagger spec (swagger/v1/swagger.json) — a Swagger 2.0 document that client-go is generated from (CCX-2836). When adding or changing schema fields, derive the description from the spec instead of hand-writing prose: run the /derive-schema-descriptions skill, which resolves the spec for the pinned client-go version, maps Terraform attributes to spec properties, and flags anything the spec doesn't describe (e.g. provider-only fields like project_id) rather than inventing text. Re-run it after bumping client-go.
Descriptions live in util.go, split into two origin-denoting constant blocks that both the resource and data source schemas reference:
apiDesc*— derived verbatim from the swagger spec (mechanical style normalization only).providerDesc*— provider-specific text with no spec basis (Terraform behavior, deprecation notes,project_idinference,common.DevelopmentMessage, etc.).
Every attribute's description is a named, origin-prefixed constant (no inline strings) so its origin is explicit. When an attribute mixes both, keep the constants separate and compose them in the schema: apiDescX + " " + providerDescX.
project_id is provider-side: providerDescProjectID = "<spec text, or the house phrase \"ID of the project the <resource> belongs to.\"> " + project.ProviderDescProjectIDFallback (the shared inference suffix lives once in internal/project). Reference implementation: internal/disk.
Style guidelines (following patterns from popular Terraform providers):
- Start directly with the noun, not "The" (e.g., "Name of the disk." not "The name of the disk.")
- Use "of the [resource]" pattern for clarity
- Keep descriptions concise - one sentence when possible
- List possible values inline with backticks:
Possible values: \value1`, `value2`.` - Put spec-derived text in
apiDesc*and provider-specific text inproviderDesc*; compose mixed-origin attributes in the schema - If the spec has no description for a mapped attribute, leave it undescribed and flag it — never invent (the
/derive-schema-descriptionsskill does this automatically)
// apiDesc* — schema descriptions derived from the client-go swagger spec (DiskV1).
const (
apiDescID = "ID of the disk."
apiDescName = "Name of the disk."
apiDescType = "Type of the disk. Possible values: `persistent-ssd`, `shared-volume`."
apiDescDNSName = "DNS name used to mount the disk. Populated only for `shared-volume` disks."
)
// providerDesc* — provider-specific schema descriptions (Terraform-side; not from the spec).
const (
providerDescProjectID = "ID of the project the disk belongs to. " + project.ProviderDescProjectIDFallback
providerDescSharedVolumeEmpty = "Empty for other disk types."
)
// Usage in schema — pure spec text references apiDesc*; mixed origin composes both.
"project_id": schema.StringAttribute{MarkdownDescription: providerDescProjectID},
"dns_name": schema.StringAttribute{MarkdownDescription: apiDescDNSName + " " + providerDescSharedVolumeEmpty},See Breaking Changes Policy for when to use deprecation vs removal.
For released resources requiring deprecation:
- Mark with
DeprecationMessageusingcommon.FormatDeprecationWithReplacement() - Keep both old and new fields functional during deprecation period
- Handle fallback logic: prefer new field, fall back to old field if new is empty
- Preserve deprecated field values from plan/state (don't overwrite from API)
Use common.ValidateHTTPStatus() for consistent status code validation.
Response body cleanup: Always close response bodies with a nil check before the error check. This ensures bodies are closed even when the API returns both an error and a response:
dataResp, httpResp, err := r.client.APIClient.MyApi.DoSomething(ctx, ...)
if httpResp != nil {
defer httpResp.Body.Close()
}
if err != nil {
resp.Diagnostics.AddError(...)
return
}Note: Most resources currently place
defer httpResp.Body.Close()after the error check. The pattern above is preferred andinternal/instance_group/serves as the reference implementation.
When schema changes require migrating existing Terraform state, create a <resource>_resource_upgrade.go file:
-
Bump schema version in the resource:
resp.Schema = schema.Schema{ Version: 1, // Increment from 0 }
-
Define prior state model for the old schema:
type myResourceModelV0 struct { ID types.String `tfsdk:"id"` OldField types.String `tfsdk:"old_field"` }
-
Implement
UpgradeStatemethod:func (r *myResource) UpgradeState(context.Context) map[int64]resource.StateUpgrader { return map[int64]resource.StateUpgrader{ 0: { PriorSchema: &schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{Computed: true}, "old_field": schema.StringAttribute{Required: true}, }, }, StateUpgrader: upgradeStateV0ToV1, }, } }
-
Write upgrader function to map old fields to new:
func upgradeStateV0ToV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { var oldState myResourceModelV0 resp.Diagnostics.Append(req.State.Get(ctx, &oldState)...) if resp.Diagnostics.HasError() { return } newState := myResourceModel{ ID: oldState.ID, NewField: oldState.OldField, // Renamed field } resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) }
Key points:
- Each upgrader jumps directly to current version (v0→v2, not v0→v1→v2)
- When adding v2, update v0 upgrader to also handle v2 changes
- Set removed/new fields to
types.StringNull()etc. (populated by Read) - Reference:
internal/instance_group/instance_group_resource_upgrade.go
Add validators directly in the schema for input constraints:
import (
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
"name": schema.StringAttribute{
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
"desired_count": schema.Int64Attribute{
Optional: true,
Validators: []validator.Int64{
int64validator.AtLeast(0),
},
},Crusoe list API endpoints don't guarantee a stable element order, so order-sensitive Terraform List attributes built from dataResp.Items re-order between reads and produce spurious diffs on otherwise-unchanged infrastructure (CCX-4394). Sort any collection built from an API response before writing state.
-
List data sources: sort the result slice with
common.SortByKeysbeforeresp.State.Set. Pass key functions as a tiebreaker chain in priority ordername → updated_at → created_at → id, supplying only the keys the model exposes (idis the always-unique final tiebreaker; use the model's unique field — e.g.Digest— when there is no id):common.SortByKeys(state.Disks, func(d diskModel) string { return d.Name }, func(d diskModel) string { return d.ID }, )
-
Flat/nested string lists of opaque IDs with no name/timestamp dimension (e.g.
vips,subnets,active_instance_ids): sort lexicographically withslices.Sortbefore assigning. -
Resource-level
Computedlists populated in API order (e.g.active_instance_ids,vips,subnets, node-poolinstance_ids): sort the slice beforecommon.StringSliceToTFList. -
Do NOT sort
Optional+Computedlists a user may set in config (e.g.kubernetes_clusteradd_ons/nodepool_ids) — sorting the read value can fight the configured order. Likewise leave nested object lists that mirror configured order (e.g. load balancernetwork_interfaces, instance_templatedisks) unless they have a clear stable key.
Sorting is non-breaking (sort-only; no schema/attribute-type/state-shape change, no state migration). Add a unit test asserting a shuffled input yields a stable, key-sorted result.
Separate test files for resource, data source, and shared utilities:
<resource>_resource_test.go - Resource-specific tests:
- Schema validators are present
- Plan modifiers (RequiresReplace, UseStateForUnknown)
- Required/optional/computed field attributes
- API-to-Terraform model mapping functions
- Resource metadata (type name)
<resource>_data_source_test.go - Data source-specific tests:
- Schema structure and nested attributes
- All nested fields are computed
- API-to-model mapping functions
- Data source metadata (type name)
util_test.go - Shared/consistency tests:
- Schema field consistency between resource and data source
- Shared description constants are defined
Example validator test:
func TestInstanceGroupResourceSchema(t *testing.T) {
ctx := context.Background()
r := NewInstanceGroupResource()
schemaResp := &resource.SchemaResponse{}
r.Schema(ctx, resource.SchemaRequest{}, schemaResp)
// Type assert to access Validators field
attr, ok := schemaResp.Schema.Attributes["desired_count"].(schema.Int64Attribute)
if !ok {
t.Fatal("desired_count attribute not found")
}
if len(attr.Validators) == 0 {
t.Error("desired_count should have validators")
}
}Located in examples/<resource>/tests/unit.tftest.hcl. Use command = plan for validation without creating resources:
variables {
name_prefix = "tf-test-"
vm_count = 3
}
run "validate_resource_name" {
command = plan
assert {
condition = my_resource.name == "${var.name_prefix}resource"
error_message = "Expected name '${var.name_prefix}resource', got '${my_resource.name}'."
}
}Limitations:
- Cannot test computed values (IDs) at plan time - use integration tests
- Provider schema validators cannot be tested with
expect_failures- use Go unit tests
Located in examples/<resource>/tests/integration.tftest.hcl. Use command = apply for full lifecycle testing:
run "create_resource" {
command = apply
assert {
condition = my_resource.id != null
error_message = "Resource was not created successfully."
}
}- Follow existing patterns in the codebase
- Run
make precommitbefore committing - Keep nil checks only where necessary (Go's
len()andappend()are nil-safe)
Watch out for these frequently triggered lint errors:
- nlreturn: Missing blank line before
returnstatements - gofumpt: Using
var x =instead ofx :=for short variable declarations - gocritic/hugeParam: Triggered when implementing Terraform Plugin Framework interfaces (e.g., validators) where the signature is fixed. Use
//nolint:gocritic // hugeParam: <param> signature required by <interface>. Example://nolint:gocritic // hugeParam: req signature required by validator.String interface
When preparing a release, commit the CHANGELOG.md and versions.env updates to main first (a dedicated release-prep commit), then open the main → release MR. Do not edit these files on the release branch or as part of the release MR, and don't squash the release MR — release-only commits make release diverge from main and cause conflicts on the next main → release merge. Ordinary feature merges into main should not touch these files; only the release-prep commit does.
See readme.md (Contributing, Versioning, and Maintaining Changelog sections) for full details on semantic versioning rules, versions.env format, changelog categories, and examples.
Use Claude Code to generate comprehensive MR descriptions based on branch changes.
# MR Title
Short descriptive title (under 72 characters)
# MR Description
## Change description
Description here
## Linked JIRA issue
Link to JIRA issue
## Related / blocking changes
MRs related to this change
## Testing Done
What testing have you done?
## Risks / Follow Ups / Relevant subsequent tickets
Any follow up issues to address? Potential security issues?
## AI Code Generation
Did you use any AI code generation tools? Please describe (which tool, model, and any other helpful context)
Closes <TICKET-ID>Ask Claude Code to fill out the MR template for the current branch:
Fill out the MR template for the changes in this branch.
Claude will analyze git diff main..HEAD and git log main..HEAD and save the output to .claude/mr-output/<branch-name>.md, ready to paste into GitLab.