Skip to content

Latest commit

 

History

History
522 lines (383 loc) · 20.5 KB

File metadata and controls

522 lines (383 loc) · 20.5 KB

CLAUDE.md

This file provides guidance to Claude Code when working with this Terraform provider.

TL;DR

  • 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>/ with main.tf and tests/*.tftest.hcl
  • ID fields: Always suffix with ID (e.g., ProjectID, InstanceTemplateID)
  • API errors: Use common.UnpackAPIError(err), not err.Error()
  • State extraction: Use the shared generic common.GetResourceModel() helper
  • Schema validators: Add stringvalidator/int64validator directly 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 priority name → updated_at → created_at → id)
  • Reference implementation: internal/instance_group/ follows all current patterns

Table of Contents

Repository Overview

This is the Crusoe Cloud Terraform Provider, enabling infrastructure-as-code management of Crusoe Cloud resources.

Build & Test Commands

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 documentation

Project Structure

internal/
├── 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

Breaking Changes Policy

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.

  1. 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,
    },
  2. Field Removal: Only after deprecation period (minimum one minor version)

  3. Behavior Changes: Must be backwards compatible or behind a new field

Major Version Release Strategy

Once all resources have been migrated to new patterns with proper deprecations:

  1. Announce deprecation timeline to customers
  2. Release major version (v1.0.0 or v2.0.0) that removes deprecated fields
  3. Document migration guide for customers

Terraform Provider Patterns

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).

Resource Structure

Each resource package follows this pattern:

  1. Resource struct with *common.CrusoeClient
  2. Model struct with types.String, types.Int64, types.List fields using tfsdk tags
  3. Schema method defining attributes with schema.StringAttribute, etc.
  4. CRUD methods: Create, Read, Update, Delete

Naming Conventions

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"`

State Extraction Helper

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
}

Error Handling

Always use common.UnpackAPIError(err) for API errors (not err.Error()).

Schema Descriptions

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_id inference, 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 in providerDesc*; 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-descriptions skill 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},

Deprecated Fields

See Breaking Changes Policy for when to use deprecation vs removal.

For released resources requiring deprecation:

  • Mark with DeprecationMessage using common.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)

HTTP Response Handling

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 and internal/instance_group/ serves as the reference implementation.

State Upgrades

When schema changes require migrating existing Terraform state, create a <resource>_resource_upgrade.go file:

  1. Bump schema version in the resource:

    resp.Schema = schema.Schema{
        Version: 1,  // Increment from 0
    }
  2. Define prior state model for the old schema:

    type myResourceModelV0 struct {
        ID       types.String `tfsdk:"id"`
        OldField types.String `tfsdk:"old_field"`
    }
  3. Implement UpgradeState method:

    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,
            },
        }
    }
  4. 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

Schema Validators

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),
    },
},

Deterministic List Ordering

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.SortByKeys before resp.State.Set. Pass key functions as a tiebreaker chain in priority order name → updated_at → created_at → id, supplying only the keys the model exposes (id is 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 with slices.Sort before assigning.

  • Resource-level Computed lists populated in API order (e.g. active_instance_ids, vips, subnets, node-pool instance_ids): sort the slice before common.StringSliceToTFList.

  • Do NOT sort Optional+Computed lists a user may set in config (e.g. kubernetes_cluster add_ons / nodepool_ids) — sorting the read value can fight the configured order. Likewise leave nested object lists that mirror configured order (e.g. load balancer network_interfaces, instance_template disks) 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.

Testing

Go Unit Tests

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")
    }
}

Terraform Unit Tests

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

Terraform Integration 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."
  }
}

Code Style

  • Follow existing patterns in the codebase
  • Run make precommit before committing
  • Keep nil checks only where necessary (Go's len() and append() are nil-safe)

Common Lint Errors

Watch out for these frequently triggered lint errors:

  • nlreturn: Missing blank line before return statements
  • gofumpt: Using var x = instead of x := 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

Changelog and Versioning

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.

Creating Merge Request Descriptions

Use Claude Code to generate comprehensive MR descriptions based on branch changes.

MR Template

# 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>

Generating an MR Description

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.