A Terraform provider for Dokploy

Plugin-framework provider covering projects, environments, applications,
Compose stacks, managed databases, domains, mounts, ports, redirects,
basic auth, registries, SSH keys, certificates and backup destinations,
over Dokploy's tRPC-over-REST API.

The shim package exposes the provider to other Go modules, which is how
pulumi-dokploy bridges it.
This commit is contained in:
2026-08-09 12:17:26 +03:00
commit a6d8aa8b52
160 changed files with 24260 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
---
name: new-terraform-provider
description: Use this when scaffolding a new Terraform provider.
license: MPL-2.0
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
To scaffold a new Terraform provider with Plugin Framework:
1. If I am already in a Terraform provider workspace, then confirm that I want
to create a new workspace. If I do not want to create a new workspace, then
skip all remaining steps.
1. Create a new workspace root directory. The root directory name should be
prefixed with "terraform-provider-". Perform all subsequent steps in this
new workspace.
1. Initialize a new Go module..
1. Run `go get -u github.com/hashicorp/terraform-plugin-framework@latest`.
1. Write a main.go file that follows [the example](assets/main.go).
1. Remove TODO comments from `main.go`
1. Run `go mod tidy`
1. Run `go build -o /dev/null`
1. Run `go test ./...`

View File

@@ -0,0 +1,43 @@
// Copyright IBM Corp. 2025, 2026
// SPDX-License-Identifier: MPL-2.0
package main
import (
"context"
"flag"
"log"
"example.org/terraform-provider-demo/internal/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
)
var (
// these will be set by the goreleaser configuration
// to appropriate values for the compiled binary.
version string = "dev"
// goreleaser can pass other information to the main package, such as the specific commit
// https://goreleaser.com/cookbooks/using-main.version/
)
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
flag.Parse()
opts := providerserver.ServeOpts{
// TODO: Update this string with the published name of your provider.
// Also update the tfplugindocs generate command to either remove the
// -provider-name flag or set its value to the updated provider name.
Address: "registry.terraform.io/example/demo",
Debug: debug,
}
err := providerserver.Serve(context.Background(), provider.New(version), opts)
if err != nil {
log.Fatal(err.Error())
}
}

View File

@@ -0,0 +1,529 @@
---
name: provider-actions
description: Implement Terraform Provider actions using the Plugin Framework. Use when developing imperative operations that execute at lifecycle events (before/after create, update, destroy).
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Terraform Provider Actions Implementation Guide
## Overview
Terraform Actions enable imperative operations during the Terraform lifecycle. Actions are experimental features that allow performing provider operations at specific lifecycle events (before/after create, update, destroy).
**References:**
- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework)
- [Terraform Actions RFC](https://github.com/hashicorp/terraform/blob/main/docs/plugin-protocol/actions.md)
## First Action Setup
When adding the first action to a provider that has never had one, several one-time scaffolding steps are required:
1. **Implement `ProviderWithActions`** — add an `Actions()` method to the provider that returns `[]func() action.Action`.
2. **Set `ActionData` in `Configure`** — the provider's `Configure` method must set `resp.ActionData = v` alongside the existing `ResourceData`, `DataSourceData`, and `EphemeralResourceData` assignments.
3. **Create `ActionWithConfigure` base type** — if the provider uses embedded base types (e.g. `ResourceWithConfigure`), create an equivalent `ActionWithConfigure` type implementing `action.ConfigureRequest` / `action.ConfigureResponse`.
4. **Action-schema helper variants** — if the provider injects common schema attributes (e.g. `namespace`) via helper functions, action-schema variants are needed since `action/schema` types differ from `resource/schema` types.
## File Structure
Actions follow the standard service package structure:
```
internal/service/<service>/
├── <action_name>_action.go # Action implementation
├── <action_name>_action_test.go # Action tests
└── service_package_gen.go # Auto-generated service registration
```
Documentation structure:
```
website/docs/actions/
└── <service>_<action_name>.html.markdown # User-facing documentation
```
Changelog entry:
```
.changelog/
└── <pr_number_or_description>.txt # Release note entry
```
## Action Schema Definition
Actions use the Terraform Plugin Framework with a standard schema pattern:
```go
func (a *actionType) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
// Required configuration parameters
"resource_id": schema.StringAttribute{
Required: true,
Description: "ID of the resource to operate on",
},
// Optional parameters with defaults
"timeout": schema.Int64Attribute{
Optional: true,
Description: "Operation timeout in seconds",
Default: int64default.StaticInt64(1800),
Computed: true,
},
},
}
}
```
### Common Schema Issues
**Pay special attention to the schema definition** - common issues after a first draft:
1. **Type Mismatches**
- Using `types.String` instead of `fwtypes.String` in model structs
- Using `types.StringType` instead of `fwtypes.StringType` in schema
- Mixing framework types with plugin-framework types
2. **List/Map Element Types**
```go
// WRONG - missing ElementType
"items": schema.ListAttribute{
Optional: true,
}
// CORRECT
"items": schema.ListAttribute{
Optional: true,
ElementType: fwtypes.StringType,
}
```
3. **Computed vs Optional**
- Attributes with defaults must be both `Optional: true` and `Computed: true`
- Don't mark action inputs as `Computed` unless they have defaults
4. **Validator Imports**
```go
// Ensure proper imports
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
```
5. **Region/Provider Attribute**
- Use framework-provided region handling when available
- Don't manually define provider-specific config in schema if framework handles it
6. **Nested Attributes**
- Use appropriate nested object types for complex structures
- Ensure nested types are properly defined
### Schema Validation Checklist
Before submitting, verify:
- [ ] All attributes have descriptions
- [ ] List/Map attributes have ElementType defined
- [ ] Validators are imported and applied correctly
- [ ] Model struct uses correct framework types
- [ ] Optional attributes with defaults are marked Computed
- [ ] Code compiles without type errors
- [ ] Run `go build` to catch type mismatches
## Action Invoke Method
The Invoke method contains the action logic:
```go
func (a *actionType) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
var data actionModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
// Create provider client
conn := a.Meta().Client(ctx)
// Progress updates for long-running operations
resp.Progress.Set(ctx, "Starting operation...")
// Implement action logic with error handling
// Use context for timeout management
// Poll for completion if async operation
resp.Progress.Set(ctx, "Operation completed")
}
```
## Key Implementation Requirements
### 1. Progress Reporting
- Use `resp.SendProgress(action.InvokeProgressEvent{...})` for real-time updates
- Provide meaningful progress messages during long operations
- Update progress at key milestones
- Include elapsed time for long operations
### 2. Timeout Management
- Always include configurable timeout parameter (default: 1800s)
- Use `context.WithTimeout()` for API calls
- Handle timeout errors gracefully
- Validate timeout ranges (typically 60-7200 seconds)
### 3. Error Handling
- Add diagnostics with `resp.Diagnostics.AddError()`
- Provide clear error messages with context
- Include API error details when relevant
- Map provider error types to user-friendly messages
- Document all possible error cases
Example error handling:
```go
// Handle specific errors
var notFound *types.ResourceNotFoundException
if errors.As(err, &notFound) {
resp.Diagnostics.AddError(
"Resource Not Found",
fmt.Sprintf("Resource %s was not found", resourceID),
)
return
}
// Generic error handling
resp.Diagnostics.AddError(
"Operation Failed",
fmt.Sprintf("Could not complete operation for %s: %s", resourceID, err),
)
```
### 4. Provider SDK Integration
- Use provider SDK clients from `a.Meta().<Service>Client(ctx)`
- Handle pagination for list operations
- Implement retry logic for transient failures
- Use appropriate error types
### 5. Parameter Validation
- Use framework validators for input validation
- Validate resource existence before operations
- Check for conflicting parameters
- Validate against provider naming requirements
### 6. Polling and Waiting
For operations that require waiting for completion:
```go
result, err := wait.WaitForStatus(ctx,
func(ctx context.Context) (wait.FetchResult[*ResourceType], error) {
// Fetch current status
resource, err := findResource(ctx, conn, id)
if err != nil {
return wait.FetchResult[*ResourceType]{}, err
}
return wait.FetchResult[*ResourceType]{
Status: wait.Status(resource.Status),
Value: resource,
}, nil
},
wait.Options[*ResourceType]{
Timeout: timeout,
Interval: wait.FixedInterval(5 * time.Second),
SuccessStates: []wait.Status{"AVAILABLE", "COMPLETED"},
TransitionalStates: []wait.Status{"CREATING", "PENDING"},
ProgressInterval: 30 * time.Second,
ProgressSink: func(fr wait.FetchResult[any], meta wait.ProgressMeta) {
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Status: %s, Elapsed: %v", fr.Status, meta.Elapsed.Round(time.Second)),
})
},
},
)
```
## Common Action Patterns
### Batch Operations
- Process items in configurable batches
- Report progress per batch
- Handle partial failures gracefully
- Support prefix/filter parameters
### Command Execution
- Submit command and get operation ID
- Poll for completion status
- Retrieve and report output
- Handle timeout during polling
- Validate resources exist before execution
### Service Invocation
- Invoke service with parameters
- Wait for completion (if synchronous)
- Return output/results
- Handle service-specific errors
### Resource State Changes
- Validate current state
- Apply state change
- Poll for target state
- Handle transitional states
### Async Job Submission
- Submit job with configuration
- Get job ID
- Optionally wait for completion
- Report job status
## Action Triggers
Actions are invoked via `action_trigger` lifecycle blocks in Terraform configurations. A standalone `action` block without a corresponding trigger is declared but never executed.
### HCL Syntax
Action parameters must be wrapped in a `config {}` block. Trigger references use the `action.` prefix, and `actions` is a list. Events are bare identifiers, not quoted strings.
```hcl
action "provider_service_action" "name" {
config {
parameter = value
}
}
resource "terraform_data" "trigger" {
lifecycle {
action_trigger {
events = [after_create]
actions = [action.provider_service_action.name]
}
}
}
```
### Available Trigger Events
**Terraform 1.14.0 Supported Events:**
- `before_create` - Before resource creation
- `after_create` - After resource creation
- `before_update` - Before resource update
- `after_update` - After resource update
**Not Supported in Terraform 1.14.0:**
- `before_destroy` - Not available (will cause validation error)
- `after_destroy` - Not available (will cause validation error)
## Testing Actions
### Acceptance Tests
- Test action invocation with valid parameters
- Test timeout scenarios
- Test error conditions
- Verify provider state changes
- Test progress reporting
- Test with custom parameters
- Test trigger-based invocation
### Test Pattern
```go
func TestAccServiceAction_basic(t *testing.T) {
ctx := acctest.Context(t)
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(ctx, t) },
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories,
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_14_0),
},
Steps: []resource.TestStep{
{
Config: testAccActionConfig_basic(),
Check: resource.ComposeTestCheckFunc(
testAccCheckResourceExists(ctx, "provider_resource.test"),
),
},
},
})
}
```
### Test Cleanup with Sweep Functions
Add sweep functions to clean up test resources:
```go
func sweepResources(region string) error {
ctx := context.Background()
client := /* get client for region */
input := &service.ListInput{
// Filter for test resources
}
var sweeperErrs *multierror.Error
pages := service.NewListPaginator(client, input)
for pages.HasMorePages() {
page, err := pages.NextPage(ctx)
if err != nil {
sweeperErrs = multierror.Append(sweeperErrs, err)
continue
}
for _, item := range page.Items {
id := item.Id
// Skip non-test resources
if !strings.HasPrefix(id, "tf-acc-test") {
continue
}
_, err := client.Delete(ctx, &service.DeleteInput{
Id: id,
})
if err != nil {
sweeperErrs = multierror.Append(sweeperErrs, err)
}
}
}
return sweeperErrs.ErrorOrNil()
}
```
### Using `terraform_data` as a No-Op Trigger
`terraform_data` can serve as a no-op trigger resource for action tests that don't need real infrastructure. This is valuable for error-case and validation tests:
```hcl
resource "terraform_data" "trigger" {
lifecycle {
action_trigger {
events = [after_create]
actions = [action.provider_service_action.test]
}
}
}
action "provider_service_action" "test" {
config {
param = "invalid-value"
}
}
```
### Using `PostApplyFunc` to Verify Side Effects
Actions don't produce state that can be checked with `resource.TestCheckResourceAttr`. Use `PostApplyFunc` on `resource.TestStep` to query the API after apply and confirm the action produced the expected side effect:
```go
Steps: []resource.TestStep{
{
Config: testConfig,
PostApplyFunc: func() {
// query the API to verify the action's side effect occurred
},
},
},
```
### Testing Best Practices
**Service-Specific Prerequisites**
- Always check for service-specific prerequisites that must be met before actions can succeed
- Document prerequisites in action documentation and test configurations
**Error Pattern Matching**
- Terraform wraps action errors with additional context
- Use flexible regex patterns: `regexache.MustCompile(\`(?s)Error Title.*key phrase\`)`
**Test Patterns Not Applicable to Actions**
1. Actions trigger on lifecycle events, not config reapplication
2. Before/After Destroy Tests: Not supported in Terraform 1.14.0
### Running Tests
Compile test to check for errors:
```bash
go test -c -o /dev/null ./internal/service/<service>
```
Run specific action tests:
```bash
TF_ACC=1 go test ./internal/service/<service> -run TestAccServiceAction_ -v
```
Run sweep to clean up test resources:
```bash
TF_ACC=1 go test ./internal/service/<service> -sweep=<region> -v
```
## Documentation Standards
Each action documentation file must include:
1. **Front Matter**
```yaml
---
subcategory: "Service Name"
layout: "provider"
page_title: "Provider: provider_service_action"
description: |-
Brief description of what the action does.
---
```
2. **Header with Warnings**
- Beta/Alpha notice about experimental status
- Warning about potential unintended consequences
- Link to provider documentation
3. **Example Usage**
- Basic usage example
- Advanced usage with all options
- Trigger-based example with `terraform_data`
- Real-world use case examples
4. **Argument Reference**
- List all required and optional arguments
- Include descriptions and defaults
- Note any validation rules
5. **Documentation Linting**
- Run `terrafmt fmt` before submission
- Verify with `terrafmt diff`
## Changelog Entry Format
Create a changelog entry in `.changelog/` directory:
```
.changelog/<pr_number_or_description>.txt
```
Content format:
```release-note:new-action
action/provider_service_action: Brief description of the action
```
## Pre-Submission Checklist
Before submitting your action implementation:
- [ ] Code compiles: `go build -o /dev/null .`
- [ ] Tests compile: `go test -c -o /dev/null ./internal/service/<service>`
- [ ] Code formatted: `make fmt`
- [ ] Documentation formatted: `terrafmt fmt website/docs/actions/<action>.html.markdown`
- [ ] Changelog entry created
- [ ] Schema uses correct types
- [ ] All List/Map attributes have ElementType
- [ ] Progress updates implemented for long operations
- [ ] Error messages include context and resource identifiers
- [ ] Documentation includes multiple examples
- [ ] Documentation includes prerequisites and warnings
## References
- [Terraform Plugin Framework Documentation](https://developer.hashicorp.com/terraform/plugin/framework)
- [Terraform Provider Development](https://developer.hashicorp.com/terraform/plugin)
- [terraform-plugin-framework GitHub](https://github.com/hashicorp/terraform-plugin-framework)
- [terraform-plugin-testing](https://github.com/hashicorp/terraform-plugin-testing)
- [Writing a Terraform Action (blog)](https://danielmschmidt.de/posts/2025-09-26-writing-a-terraform-action/)
- Reference implementations: `terraform-provider-tfe` (`action_query_run.go`, `action_query_run_test.go`), `terraform-provider-vault` (`action_rotate_root.go`)

View File

@@ -0,0 +1,67 @@
---
name: provider-docs
description: Create, update, and review Terraform provider documentation for Terraform Registry using HashiCorp-recommended patterns, tfplugindocs templates, and schema descriptions. Use when adding or changing provider configuration, resources, data sources, ephemeral resources, list resources, functions, or guides; when validating generated docs; and when troubleshooting missing or incorrect Registry documentation.
---
# Terraform Provider Docs
## Follow This Workflow
1. Confirm scope and documentation targets.
- Map code changes to the exact doc targets: provider index, resources, data sources, ephemeral resources, list resources, functions, or guides.
- Decide whether content should come from schema descriptions, templates, or both.
2. Write schema descriptions first.
- Add precise user-facing descriptions to schema fields so generated docs stay aligned with behavior.
- Keep wording specific to argument purpose, constraints, defaults, and computed behavior.
3. Add or update template files in `docs/`.
- Create only files that map to implemented provider objects.
- Use HashiCorp-recommended template paths:
- `docs/index.md.tmpl`
- `docs/data-sources/<name>.md.tmpl`
- `docs/resources/<name>.md.tmpl`
- `docs/ephemeral-resources/<name>.md.tmpl`
- `docs/list-resources/<name>.md.tmpl`
- `docs/functions/<name>.md.tmpl`
- `docs/guides/<name>.md.tmpl`
- Keep templates focused on overview and examples; rely on generated sections for field-by-field details.
4. Generate documentation with `tfplugindocs`.
- Prefer repository defaults when configured:
```bash
go generate ./...
```
- Otherwise run the generator directly:
```bash
go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name <provider_name>
```
- Re-run generation after every schema or template edit.
5. Validate the generated markdown.
- Verify files in `docs/` match the current provider implementation.
- Verify examples are valid HCL and reflect current argument/attribute names.
- Verify required/optional/computed semantics in docs match schema behavior.
6. Apply Registry publication rules before release.
- Use semantic version tags prefixed with `v` (for example `v1.2.3`).
- Create release tags from the default branch.
- Keep `terraform-registry-manifest.json` in the repository root.
- Expect docs to be versioned in Registry and switchable with the version selector.
7. Preview or troubleshoot publication when needed.
- Use the HashiCorp preview process to inspect rendered docs before release when accuracy risk is high.
- If docs are missing in Registry, check tag format, tag source branch, manifest file presence, and provider publication status.
## Enforce Quality Bar
- Keep documentation behaviorally accurate; never describe unsupported arguments or attributes.
- Keep examples minimal, realistic, and runnable.
- Keep terminology and naming consistent across provider, resources, and data sources.
- Avoid duplicating generated argument/attribute blocks in manual templates.
- Keep doc changes tied to the same PR as schema/API changes whenever possible.
## Load References On Demand
- Read `references/hashicorp-provider-docs.md` for source-backed rules and official links.
- Load only the sections needed for the current change to keep context lean.

View File

@@ -0,0 +1,7 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
interface:
display_name: "Terraform Provider Docs"
short_description: "Best practices for Terraform provider docs"
default_prompt: "Use $terraform-provider-docs to create or update Terraform Registry provider documentation with HashiCorp-aligned structure and style."

View File

@@ -0,0 +1,65 @@
# HashiCorp Provider Documentation Reference
Source of truth for this skill:
- https://developer.hashicorp.com/terraform/registry/providers/docs
## Core Rules
- Publish provider docs through Terraform Registry using `tfplugindocs`.
- Generate provider docs from schema descriptions and markdown templates.
- Store templates under the repository `docs/` directory with expected naming conventions.
- Keep release tags and manifest metadata valid so Registry can render and display docs.
## Template Paths
Use these template paths when the corresponding provider objects exist:
- `docs/index.md.tmpl`
- `docs/data-sources/<name>.md.tmpl`
- `docs/resources/<name>.md.tmpl`
- `docs/ephemeral-resources/<name>.md.tmpl`
- `docs/list-resources/<name>.md.tmpl`
- `docs/functions/<name>.md.tmpl`
- `docs/guides/<name>.md.tmpl`
## Generation Workflow
HashiCorp recommends wiring generator execution through `go generate`:
```go
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name <provider_name>
```
Run from repository root:
```bash
go generate ./...
```
Alternative direct execution:
```bash
go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name <provider_name>
```
## Release and Publication Constraints
- Use semantic version tags prefixed with `v`.
- Create tags from the default branch.
- Keep `terraform-registry-manifest.json` in the repository root.
- Understand docs appear by provider version in Registry once the provider release is published.
## Preview and Troubleshooting
- Use HashiCorp's preview process to verify rendering before release when needed.
- If docs are missing or stale in Registry, verify:
- tag naming and tag branch source
- manifest file presence and validity
- provider version publication state
## Related Canonical Pages
- Provider docs guidance:
- https://developer.hashicorp.com/terraform/registry/providers/docs
- Terraform Plugin Docs (`tfplugindocs`) source and usage:
- https://github.com/hashicorp/terraform-plugin-docs

View File

@@ -0,0 +1,599 @@
---
name: provider-resources
description: Implement Terraform Provider resources and data sources using the Plugin Framework. Use when developing CRUD operations, schema design, state management, and acceptance testing for provider resources.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Terraform Provider Resources Implementation Guide
## Overview
This guide covers developing Terraform Provider resources and data sources using the Terraform Plugin Framework. Resources represent infrastructure objects that Terraform manages through Create, Read, Update, and Delete (CRUD) operations.
**References:**
- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework)
- [Resource Development](https://developer.hashicorp.com/terraform/plugin/framework/resources)
- [Data Source Development](https://developer.hashicorp.com/terraform/plugin/framework/data-sources)
## File Structure
Resources follow the standard service package structure:
```
internal/service/<service>/
├── <resource_name>.go # Resource implementation
├── <resource_name>_test.go # Acceptance tests
├── <resource_name>_data_source.go # Data source (if applicable)
├── find.go # Finder functions
├── exports_test.go # Test exports
└── service_package_gen.go # Auto-generated registration
```
Documentation structure:
```
website/docs/r/
└── <service>_<resource_name>.html.markdown # Resource documentation
website/docs/d/
└── <service>_<resource_name>.html.markdown # Data source documentation
```
## Resource Structure
### SDKv2 Resource Pattern
```go
func ResourceExample() *schema.Resource {
return &schema.Resource{
CreateWithoutTimeout: resourceExampleCreate,
ReadWithoutTimeout: resourceExampleRead,
UpdateWithoutTimeout: resourceExampleUpdate,
DeleteWithoutTimeout: resourceExampleDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.StringLenBetween(1, 255),
},
"arn": {
Type: schema.TypeString,
Computed: true,
},
"tags": tftags.TagsSchema(),
"tags_all": tftags.TagsSchemaComputed(),
},
CustomizeDiff: verify.SetTagsDiff,
}
}
```
### Plugin Framework Resource Pattern
```go
type resourceExample struct {
framework.ResourceWithConfigure
}
func (r *resourceExample) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_example"
}
func (r *resourceExample) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": framework.IDAttribute(),
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthBetween(1, 255),
},
},
"arn": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
```
## CRUD Operations
### Create Operation
```go
func (r *resourceExample) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data resourceExampleModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
conn := r.Meta().ExampleClient(ctx)
input := &example.CreateExampleInput{
Name: data.Name.ValueStringPointer(),
}
output, err := conn.CreateExample(ctx, input)
if err != nil {
resp.Diagnostics.AddError(
"Error creating Example",
fmt.Sprintf("Could not create example %s: %s", data.Name.ValueString(), err),
)
return
}
data.ID = types.StringPointerValue(output.Id)
data.ARN = types.StringPointerValue(output.Arn)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```
### Read Operation
```go
func (r *resourceExample) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data resourceExampleModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
conn := r.Meta().ExampleClient(ctx)
output, err := findExampleByID(ctx, conn, data.ID.ValueString())
if tfresource.NotFound(err) {
resp.Diagnostics.AddWarning(
"Resource not found",
fmt.Sprintf("Example %s not found, removing from state", data.ID.ValueString()),
)
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError(
"Error reading Example",
fmt.Sprintf("Could not read example %s: %s", data.ID.ValueString(), err),
)
return
}
data.Name = types.StringPointerValue(output.Name)
data.ARN = types.StringPointerValue(output.Arn)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
```
### Update Operation
```go
func (r *resourceExample) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state resourceExampleModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
conn := r.Meta().ExampleClient(ctx)
if !plan.Description.Equal(state.Description) {
input := &example.UpdateExampleInput{
Id: plan.ID.ValueStringPointer(),
Description: plan.Description.ValueStringPointer(),
}
_, err := conn.UpdateExample(ctx, input)
if err != nil {
resp.Diagnostics.AddError(
"Error updating Example",
fmt.Sprintf("Could not update example %s: %s", plan.ID.ValueString(), err),
)
return
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
```
### Delete Operation
```go
func (r *resourceExample) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data resourceExampleModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
conn := r.Meta().ExampleClient(ctx)
_, err := conn.DeleteExample(ctx, &example.DeleteExampleInput{
Id: data.ID.ValueStringPointer(),
})
if tfresource.NotFound(err) {
return
}
if err != nil {
resp.Diagnostics.AddError(
"Error deleting Example",
fmt.Sprintf("Could not delete example %s: %s", data.ID.ValueString(), err),
)
return
}
}
```
## Schema Design
### Attribute Types
| Terraform Type | Framework Type | Use Case |
|----------------|----------------|----------|
| `string` | `schema.StringAttribute` | Names, ARNs, IDs |
| `number` | `schema.Int64Attribute`, `schema.Float64Attribute` | Counts, sizes |
| `bool` | `schema.BoolAttribute` | Feature flags |
| `list` | `schema.ListAttribute` | Ordered collections |
| `set` | `schema.SetAttribute` | Unordered unique items |
| `map` | `schema.MapAttribute` | Key-value pairs |
| `object` | `schema.SingleNestedAttribute` | Complex nested config |
### Plan Modifiers
```go
// Force replacement when value changes
stringplanmodifier.RequiresReplace()
// Preserve unknown value during plan
stringplanmodifier.UseStateForUnknown()
// Custom plan modifier
stringplanmodifier.RequiresReplaceIf(
func(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) {
// Custom logic
},
"description",
"markdown description",
)
```
### Validators
```go
// String validators
stringvalidator.LengthBetween(1, 255)
stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z0-9-]+$`), "must be lowercase alphanumeric with hyphens")
stringvalidator.OneOf("option1", "option2", "option3")
// Int64 validators
int64validator.Between(1, 100)
int64validator.AtLeast(1)
int64validator.AtMost(1000)
// List validators
listvalidator.SizeAtLeast(1)
listvalidator.SizeAtMost(10)
```
### Sensitive Attributes
```go
"password": schema.StringAttribute{
Required: true,
Sensitive: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(8),
},
}
```
## State Management
### Handling Resource Not Found
```go
func findExampleByID(ctx context.Context, conn *example.Client, id string) (*example.Example, error) {
input := &example.GetExampleInput{
Id: &id,
}
output, err := conn.GetExample(ctx, input)
if err != nil {
var notFound *types.ResourceNotFoundException
if errors.As(err, &notFound) {
return nil, &retry.NotFoundError{
LastError: err,
LastRequest: input,
}
}
return nil, err
}
if output == nil || output.Example == nil {
return nil, tfresource.NewEmptyResultError(input)
}
return output.Example, nil
}
```
### Waiting for Resource States
```go
func waitExampleCreated(ctx context.Context, conn *example.Client, id string, timeout time.Duration) (*example.Example, error) {
stateConf := &retry.StateChangeConf{
Pending: []string{"CREATING", "PENDING"},
Target: []string{"ACTIVE", "AVAILABLE"},
Refresh: statusExample(ctx, conn, id),
Timeout: timeout,
}
outputRaw, err := stateConf.WaitForStateContext(ctx)
if output, ok := outputRaw.(*example.Example); ok {
return output, err
}
return nil, err
}
func statusExample(ctx context.Context, conn *example.Client, id string) retry.StateRefreshFunc {
return func() (interface{}, string, error) {
output, err := findExampleByID(ctx, conn, id)
if tfresource.NotFound(err) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
return output, string(output.Status), nil
}
}
```
## Testing
### Basic Acceptance Test
```go
func TestAccExampleResource_basic(t *testing.T) {
ctx := acctest.Context(t)
rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix)
resourceName := "provider_example.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(ctx, t) },
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories,
CheckDestroy: testAccCheckExampleDestroy(ctx),
Steps: []resource.TestStep{
{
Config: testAccExampleConfig_basic(rName),
Check: resource.ComposeTestCheckFunc(
testAccCheckExampleExists(ctx, resourceName),
resource.TestCheckResourceAttr(resourceName, "name", rName),
resource.TestCheckResourceAttrSet(resourceName, "arn"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
},
},
})
}
```
### Disappears Test
```go
func TestAccExampleResource_disappears(t *testing.T) {
ctx := acctest.Context(t)
rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix)
resourceName := "provider_example.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(ctx, t) },
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories,
CheckDestroy: testAccCheckExampleDestroy(ctx),
Steps: []resource.TestStep{
{
Config: testAccExampleConfig_basic(rName),
Check: resource.ComposeTestCheckFunc(
testAccCheckExampleExists(ctx, resourceName),
acctest.CheckResourceDisappears(ctx, acctest.Provider, ResourceExample(), resourceName),
),
ExpectNonEmptyPlan: true,
},
},
})
}
```
### Test Helper Functions
```go
func testAccCheckExampleExists(ctx context.Context, name string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[name]
if !ok {
return fmt.Errorf("Not found: %s", name)
}
conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx)
_, err := findExampleByID(ctx, conn, rs.Primary.ID)
return err
}
}
func testAccCheckExampleDestroy(ctx context.Context) resource.TestCheckFunc {
return func(s *terraform.State) error {
conn := acctest.Provider.Meta().(*conns.Client).ExampleClient(ctx)
for _, rs := range s.RootModule().Resources {
if rs.Type != "provider_example" {
continue
}
_, err := findExampleByID(ctx, conn, rs.Primary.ID)
if tfresource.NotFound(err) {
continue
}
if err != nil {
return err
}
return fmt.Errorf("Example %s still exists", rs.Primary.ID)
}
return nil
}
}
```
### Running Tests
```bash
# Compile tests
go test -c -o /dev/null ./internal/service/<service>
# Run acceptance tests
TF_ACC=1 go test ./internal/service/<service> -run TestAccExample -v -timeout 60m
# Run with specific provider version
TF_ACC=1 go test ./internal/service/<service> -run TestAccExample -v
# Run sweeper to clean up
TF_ACC=1 go test ./internal/service/<service> -sweep=<region> -v
```
## Error Handling
### Common Error Patterns
```go
// Handle specific API errors
var notFound *types.ResourceNotFoundException
if errors.As(err, &notFound) {
// Resource doesn't exist
}
var conflict *types.ConflictException
if errors.As(err, &conflict) {
// Resource state conflict
}
var throttle *types.ThrottlingException
if errors.As(err, &throttle) {
// Rate limited - SDK handles retry
}
```
### Diagnostics
```go
// Add error
resp.Diagnostics.AddError(
"Error creating resource",
fmt.Sprintf("Could not create resource: %s", err),
)
// Add warning
resp.Diagnostics.AddWarning(
"Resource modified outside Terraform",
"Resource was modified outside of Terraform, state may be inconsistent",
)
// Add attribute error
resp.Diagnostics.AddAttributeError(
path.Root("name"),
"Invalid name",
"Name must be lowercase alphanumeric",
)
```
## Documentation Standards
### Resource Documentation
```markdown
---
subcategory: "Service Name"
layout: "provider"
page_title: "Provider: provider_example"
description: |-
Manages an Example resource.
---
# Resource: provider_example
Manages an Example resource.
## Example Usage
### Basic Usage
\```hcl
resource "provider_example" "example" {
name = "my-example"
}
\```
## Argument Reference
* `name` - (Required) Name of the example.
* `description` - (Optional) Description of the example.
## Attribute Reference
* `id` - ID of the example.
* `arn` - ARN of the example.
## Import
Example can be imported using the ID:
\```
$ terraform import provider_example.example example-id-12345
\```
```
## Pre-Submission Checklist
- [ ] Code compiles without errors
- [ ] All tests pass locally
- [ ] Resource has all CRUD operations implemented
- [ ] Import is implemented and tested
- [ ] Disappears test is included
- [ ] Documentation is complete with examples
- [ ] Error messages are clear and actionable
- [ ] Sensitive attributes are marked
- [ ] Plan modifiers are appropriate
- [ ] Validators cover edge cases
## References
- [Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework)
- [Terraform Plugin SDKv2](https://developer.hashicorp.com/terraform/plugin/sdkv2)
- [Acceptance Testing](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests)
- [terraform-plugin-framework GitHub](https://github.com/hashicorp/terraform-plugin-framework)

View File

@@ -0,0 +1,414 @@
---
name: provider-test-patterns
description: >-
Terraform provider acceptance test patterns using terraform-plugin-testing
with the Plugin Framework. Covers test structure, TestCase/TestStep fields,
ConfigStateChecks with custom statecheck.StateCheck implementations,
plan checks, CompareValue for cross-step assertions, config helpers,
import testing with ImportStateKind, sweepers, and scenario patterns
(basic, update, disappears, validation, regression), and ephemeral resource
testing with the echoprovider package. Use when writing, reviewing, or
debugging provider acceptance tests, including questions about statecheck,
plancheck, TestCheckFunc, CheckDestroy, ExpectError, import state
verification, ephemeral resources, or how to structure test files.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Provider Acceptance Test Patterns
Patterns for writing acceptance tests using
[terraform-plugin-testing](https://github.com/hashicorp/terraform-plugin-testing)
with the [Plugin Framework](https://github.com/hashicorp/terraform-plugin-framework).
Source: [HashiCorp Testing Patterns](https://developer.hashicorp.com/terraform/plugin/testing/testing-patterns)
**References** (load when needed):
- `references/checks.md` — statecheck, plancheck, knownvalue types, tfjsonpath, comparers
- `references/sweepers.md` — sweeper setup, TestMain, dependencies
- `references/ephemeral.md` — ephemeral resource testing, echoprovider, multi-step patterns
---
## Test Lifecycle
The framework runs each TestStep through: **plan → apply → refresh → final
plan**. If the final plan shows a diff, the test fails (unless
`ExpectNonEmptyPlan` is set). After all steps, destroy runs followed by
`CheckDestroy`. This means every test automatically verifies that
configurations apply cleanly and produce no drift — no assertions needed for
that.
---
## Test Function Structure
```go
func TestAccExample_basic(t *testing.T) {
var widget example.Widget
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
resourceName := "example_widget.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckExampleDestroy,
Steps: []resource.TestStep{
{
Config: testAccExampleConfig_basic(rName),
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, &widget),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("name"), knownvalue.StringExact(rName)),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("id"), knownvalue.NotNull()),
},
},
},
})
}
```
Use `resource.ParallelTest` by default. Use `resource.Test` only when tests
share state or cannot run concurrently.
---
## Provider Factory
```go
// provider_test.go — Plugin Framework with Protocol 6 (use Protocol5 variant if needed)
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"example": providerserver.NewProtocol6WithError(New("test")()),
}
```
---
## TestCase Fields
| Field | Purpose |
|-------|---------|
| `PreCheck` | `func()` — verify prerequisites (env vars, API access) |
| `ProtoV6ProviderFactories` | Plugin Framework provider factories |
| `CheckDestroy` | `TestCheckFunc` — verify resources destroyed after all steps |
| `Steps` | `[]TestStep` — sequential test operations |
| `TerraformVersionChecks` | `[]tfversion.TerraformVersionCheck` — gate by CLI version |
---
## TestStep Fields
### Config Mode
| Field | Purpose |
|-------|---------|
| `Config` | Inline HCL string to apply |
| `ConfigStateChecks` | `[]statecheck.StateCheck` — modern assertions (preferred) |
| `ConfigPlanChecks` | `resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}` |
| `ExpectError` | `*regexp.Regexp` — expect failure matching pattern |
| `ExpectNonEmptyPlan` | `bool` — expect non-empty plan after apply |
| `PlanOnly` | `bool` — plan without applying |
| `Destroy` | `bool` — run destroy step |
| `PreConfig` | `func()` — setup before step |
### Import Mode
| Field | Purpose |
|-------|---------|
| `ImportState` | `true` to enable import mode |
| `ImportStateVerify` | Verify imported state matches prior state |
| `ImportStateVerifyIgnore` | `[]string` — attributes to skip during verify |
| `ImportStateKind` | `resource.ImportBlockWithID` — import block generation |
| `ResourceName` | Resource address to import |
| `ImportStateId` | Override the ID used for import |
---
## Check Functions
### Modern: ConfigStateChecks (preferred)
Type-safe with aggregated error reporting. Compose built-in checks with custom
`statecheck.StateCheck` implementations. See `references/checks.md` for full
knownvalue types, tfjsonpath navigation, and comparers.
```go
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, &widget),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("name"), knownvalue.StringExact("my-widget")),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("enabled"), knownvalue.Bool(true)),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("id"), knownvalue.NotNull()),
statecheck.ExpectSensitiveValue(resourceName,
tfjsonpath.New("api_key")),
},
```
Do not mix `Check` (legacy) and `ConfigStateChecks` in the same step.
### Legacy: Check (for CheckDestroy and migration)
`CheckDestroy` on `TestCase` requires `TestCheckFunc`. The `Check` field on
`TestStep` also accepts `TestCheckFunc` but prefer `ConfigStateChecks` for new
tests.
```go
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(name, "key", "expected"),
resource.TestCheckResourceAttrSet(name, "id"),
resource.TestCheckNoResourceAttr(name, "removed"),
resource.TestMatchResourceAttr(name, "url", regexp.MustCompile(`^https://`)),
resource.TestCheckResourceAttrPair(res1, "ref_id", res2, "id"),
),
```
`ComposeAggregateTestCheckFunc` reports all errors; `ComposeTestCheckFunc`
fails fast on the first.
---
## Config Helpers
Use numbered format verbs — `%[1]q` for quoted strings, `%[1]s` for raw:
```go
func testAccExampleConfig_basic(rName string) string {
return fmt.Sprintf(`
resource "example_widget" "test" {
name = %[1]q
}
`, rName)
}
func testAccExampleConfig_full(rName, description string) string {
return fmt.Sprintf(`
resource "example_widget" "test" {
name = %[1]q
description = %[2]q
enabled = true
}
`, rName, description)
}
```
---
## Scenario Patterns
### Basic + Update (combine in one test — updates are supersets of basic)
```go
Steps: []resource.TestStep{
{
Config: testAccExampleConfig_basic(rName),
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, &widget),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("name"), knownvalue.StringExact(rName)),
},
},
{
Config: testAccExampleConfig_full(rName, "updated"),
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, &widget),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("description"), knownvalue.StringExact("updated")),
},
},
},
```
### Import
After a config step, verify import produces identical state. Use
`ImportStateKind` for import block generation:
```go
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateKind: resource.ImportBlockWithID,
},
```
### Disappears (resource deleted externally)
```go
{
Config: testAccExampleConfig_basic(rName),
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, &widget),
stateCheckExampleDisappears(resourceName),
},
ExpectNonEmptyPlan: true,
},
```
### Validation (expect error)
```go
{
Config: testAccExampleConfig_invalidName(""),
ExpectError: regexp.MustCompile(`name must not be empty`),
},
```
### Regression (two-commit workflow)
A proper bug fix uses at least two commits: first commit the regression test
(which fails, confirming the bug), then commit the fix (test passes). This
lets reviewers independently verify the test reproduces the issue by checking
out the first commit, then advancing to the fix.
Name and document regression tests to identify the issue they fix. Include a
link to the original bug report when possible.
```go
// TestAccExample_regressionGH1234 verifies fix for https://github.com/org/repo/issues/1234
func TestAccExample_regressionGH1234(t *testing.T) {
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
resourceName := "example_widget.test"
resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckExampleDestroy,
Steps: []resource.TestStep{
{
// Reproduce the issue: this config triggered the bug
Config: testAccExampleConfig_regressionGH1234(rName),
ConfigStateChecks: []statecheck.StateCheck{
stateCheckExampleExists(resourceName, nil),
statecheck.ExpectKnownValue(resourceName,
tfjsonpath.New("computed_field"), knownvalue.NotNull()),
},
},
},
})
}
```
---
## Helper Functions
### Custom StateCheck: Exists
Implement `statecheck.StateCheck` for API existence verification. Separate the
exists check into its own function for reuse across steps — the source
recommends this as a design principle:
```go
type exampleExistsCheck struct {
resourceAddress string
widget *example.Widget
}
func (e exampleExistsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
r, err := stateResourceAtAddress(req.State, e.resourceAddress)
if err != nil {
resp.Error = err
return
}
id, ok := r.AttributeValues["id"].(string)
if !ok {
resp.Error = fmt.Errorf("no id found for %s", e.resourceAddress)
return
}
conn := testAccAPIClient()
widget, err := conn.GetWidget(id)
if err != nil {
resp.Error = fmt.Errorf("%s not found via API: %w", e.resourceAddress, err)
return
}
if e.widget != nil {
*e.widget = *widget
}
}
func stateCheckExampleExists(name string, widget *example.Widget) statecheck.StateCheck {
return exampleExistsCheck{resourceAddress: name, widget: widget}
}
```
### Custom StateCheck: Disappears
Delete a resource via API to simulate external deletion:
```go
type exampleDisappearsCheck struct {
resourceAddress string
}
func (e exampleDisappearsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
r, err := stateResourceAtAddress(req.State, e.resourceAddress)
if err != nil {
resp.Error = err
return
}
id := r.AttributeValues["id"].(string)
conn := testAccAPIClient()
resp.Error = conn.DeleteWidget(id)
}
func stateCheckExampleDisappears(name string) statecheck.StateCheck {
return exampleDisappearsCheck{resourceAddress: name}
}
```
### State Resource Lookup (shared utility)
```go
func stateResourceAtAddress(state *tfjson.State, address string) (*tfjson.StateResource, error) {
if state == nil || state.Values == nil || state.Values.RootModule == nil {
return nil, fmt.Errorf("no state available")
}
for _, r := range state.Values.RootModule.Resources {
if r.Address == address {
return r, nil
}
}
return nil, fmt.Errorf("not found in state: %s", address)
}
```
### Destroy Check (TestCheckFunc — required by CheckDestroy)
```go
func testAccCheckExampleDestroy(s *terraform.State) error {
conn := testAccAPIClient()
for _, rs := range s.RootModule().Resources {
if rs.Type != "example_widget" {
continue
}
_, err := conn.GetWidget(rs.Primary.ID)
if err == nil {
return fmt.Errorf("widget %s still exists", rs.Primary.ID)
}
if !isNotFoundError(err) {
return err
}
}
return nil
}
```
### PreCheck
```go
func testAccPreCheck(t *testing.T) {
t.Helper()
if os.Getenv("EXAMPLE_API_KEY") == "" {
t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
}
}
```

View File

@@ -0,0 +1,231 @@
# State Checks and Plan Checks Reference
Detailed reference for `statecheck` and `plancheck` packages from
`terraform-plugin-testing`. Read this when writing assertions for test steps.
Source: [State Checks](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/state-checks/resource),
[Plan Checks](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/plan-checks)
---
## Table of Contents
1. [State Checks](#state-checks)
2. [Known Value Types](#known-value-types)
3. [tfjsonpath Navigation](#tfjsonpath-navigation)
4. [Value Comparers](#value-comparers)
5. [Plan Checks](#plan-checks)
---
## State Checks
Use via `ConfigStateChecks` field on `TestStep`. All assertion errors are
aggregated and reported together.
### ExpectKnownValue
Assert an attribute has a specific type and value:
```go
statecheck.ExpectKnownValue("example_widget.test",
tfjsonpath.New("name"),
knownvalue.StringExact("my-widget"))
```
### ExpectSensitiveValue
Assert an attribute is marked sensitive (requires Terraform 1.4.6+):
```go
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_4_6),
},
// ...
statecheck.ExpectSensitiveValue("example_widget.test",
tfjsonpath.New("api_key"))
```
### CompareValue
Compare the same attribute across sequential test steps:
```go
compareValuesSame := statecheck.CompareValue(compare.ValuesSame())
Steps: []resource.TestStep{
{
Config: testAccConfig_v1(rName),
ConfigStateChecks: []statecheck.StateCheck{
compareValuesSame.AddStateValue("example_widget.test",
tfjsonpath.New("id")),
},
},
{
Config: testAccConfig_v2(rName),
ConfigStateChecks: []statecheck.StateCheck{
compareValuesSame.AddStateValue("example_widget.test",
tfjsonpath.New("id")),
},
},
},
```
### CompareValuePairs
Compare attributes between two resources:
```go
statecheck.CompareValuePairs(
"example_widget.test", tfjsonpath.New("vpc_id"),
"example_vpc.test", tfjsonpath.New("id"),
compare.ValuesSame())
```
### CompareValueCollection
Check if a value exists in a collection attribute:
```go
statecheck.CompareValueCollection(
"example_widget.test", tfjsonpath.New("tags"),
"example_widget.test", tfjsonpath.New("name"),
compare.ValuesSame())
```
---
## Known Value Types
Use with `ExpectKnownValue` to assert attribute values:
| Type | Example |
|------|---------|
| `knownvalue.StringExact("value")` | Exact string match |
| `knownvalue.StringRegexp(regexp.MustCompile(`^arn:`))` | Regex match |
| `knownvalue.Bool(true)` | Boolean value |
| `knownvalue.Int64Exact(42)` | Exact int64 |
| `knownvalue.Float64Exact(3.14)` | Exact float64 |
| `knownvalue.NotNull()` | Value is set (not null) |
| `knownvalue.Null()` | Value is null |
| `knownvalue.ListExact([]knownvalue.Check{...})` | Exact list match |
| `knownvalue.ListPartial(map[int]knownvalue.Check{0: ...})` | Partial list match |
| `knownvalue.ListSizeExact(3)` | List has N elements |
| `knownvalue.SetExact([]knownvalue.Check{...})` | Exact set match |
| `knownvalue.SetPartial([]knownvalue.Check{...})` | Set contains items |
| `knownvalue.SetSizeExact(2)` | Set has N elements |
| `knownvalue.MapExact(map[string]knownvalue.Check{...})` | Exact map match |
| `knownvalue.MapPartial(map[string]knownvalue.Check{...})` | Map contains keys |
| `knownvalue.MapSizeExact(1)` | Map has N keys |
| `knownvalue.ObjectExact(map[string]knownvalue.Check{...})` | Exact object match |
| `knownvalue.ObjectPartial(map[string]knownvalue.Check{...})` | Object has attributes |
| `knownvalue.Float32Exact(1.5)` | Exact float32 |
| `knownvalue.Int32Exact(42)` | Exact int32 |
| `knownvalue.NumberExact(big.NewFloat(42))` | Exact number (`*big.Float`) |
| `knownvalue.TupleExact([]knownvalue.Check{...})` | Exact tuple match |
| `knownvalue.TuplePartial(map[int]knownvalue.Check{0: ...})` | Partial tuple match |
| `knownvalue.TupleSizeExact(3)` | Tuple has N elements |
### Nested Value Example
```go
statecheck.ExpectKnownValue("example_widget.test",
tfjsonpath.New("settings"),
knownvalue.ObjectExact(map[string]knownvalue.Check{
"mode": knownvalue.StringExact("production"),
"enabled": knownvalue.Bool(true),
}))
```
---
## tfjsonpath Navigation
Navigate nested attributes in state:
```go
tfjsonpath.New("attribute") // top-level attribute
tfjsonpath.New("block").AtMapKey("key") // nested map/object key
tfjsonpath.New("list_attr").AtSliceIndex(0) // list element by index
tfjsonpath.New("block").AtMapKey("nested").AtMapKey("deep") // deep nesting
```
---
## Value Comparers
Use with `CompareValue`, `CompareValuePairs`, `CompareValueCollection`:
| Comparer | Purpose |
|----------|---------|
| `compare.ValuesSame()` | Values are identical |
| `compare.ValuesDiffer()` | Values are different |
---
## Plan Checks
Use via `ConfigPlanChecks` or `RefreshPlanChecks` on `TestStep`. Plan checks
inspect the plan file at specific phases.
### ConfigPlanChecks Phases
```go
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{...}, // after plan, before apply
PostApplyPreRefresh: []plancheck.PlanCheck{...}, // after apply, before refresh
PostApplyPostRefresh: []plancheck.PlanCheck{...}, // after refresh
},
```
### Built-in Plan Checks
```go
// Expect no changes in plan
plancheck.ExpectEmptyPlan()
// Expect changes in plan
plancheck.ExpectNonEmptyPlan()
// Expect specific resource action
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionCreate)
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionUpdate)
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionDestroy)
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionNoop)
// Expect known plan value
plancheck.ExpectKnownValue("example_widget.test",
tfjsonpath.New("name"),
knownvalue.StringExact("my-widget"))
// Expect unknown (computed) value in plan
plancheck.ExpectUnknownValue("example_widget.test",
tfjsonpath.New("computed_field"))
// Expect sensitive value in plan
plancheck.ExpectSensitiveValue("example_widget.test",
tfjsonpath.New("api_key"))
```
### No-Op After Update Example
Verify that updating a config back to original values produces no diff:
```go
Steps: []resource.TestStep{
{
Config: testAccConfig_basic(rName),
},
{
Config: testAccConfig_updated(rName),
},
{
Config: testAccConfig_basic(rName),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
},
},
},
```

View File

@@ -0,0 +1,208 @@
# Ephemeral Resource Testing Reference
Testing patterns for ephemeral resources using `terraform-plugin-testing`.
Ephemeral resources reference external data without persisting it to plan or
state artifacts, which means standard plan checks and state checks cannot
directly assert on ephemeral resource data.
Source: [Ephemeral Resource Acceptance Tests](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/ephemeral-resources)
**Requires Terraform >= 1.10.0** — gate all ephemeral tests with
`tfversion.SkipBelow(tfversion.Version1_10_0)`.
---
## Table of Contents
1. [Testing Approaches](#testing-approaches)
2. [Direct Integration Testing](#direct-integration-testing)
3. [Echo Provider Pattern](#echo-provider-pattern)
4. [Multi-Step Testing](#multi-step-testing)
---
## Testing Approaches
Two strategies for testing ephemeral resources:
| Approach | When to use |
|----------|-------------|
| **Direct integration** | Verify the ephemeral resource successfully provides data to a dependent resource or provider |
| **Echo provider** | Assert on specific attribute values using `ConfigStateChecks` via the `echoprovider` package |
---
## Direct Integration Testing
Test that an ephemeral resource successfully provides data to a dependent
resource. No direct assertions on ephemeral data — the test passes if the
dependent resource applies cleanly.
```go
func TestExampleCloudSecret_DnsKerberos(t *testing.T) {
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_10_0),
},
ExternalProviders: map[string]resource.ExternalProvider{
"dns": {
Source: "hashicorp/dns",
},
},
ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){
"examplecloud": providerserver.NewProtocol5WithError(New()),
},
Steps: []resource.TestStep{
{
Config: `
ephemeral "examplecloud_secret" "krb" {
name = "example_kerberos_user"
}
provider "dns" {
update {
server = "ns.example.com"
gssapi {
realm = ephemeral.examplecloud_secret.krb.secret_data.realm
username = ephemeral.examplecloud_secret.krb.secret_data.username
password = ephemeral.examplecloud_secret.krb.secret_data.password
}
}
}
resource "dns_a_record_set" "record_set" {
zone = "example.com."
addresses = ["192.168.0.1", "192.168.0.2", "192.168.0.3"]
}
`,
},
},
})
}
```
---
## Echo Provider Pattern
The `echoprovider` package (Protocol V6) captures ephemeral data into a
managed resource's state, making it assertable with standard
`ConfigStateChecks`.
### Setup
Register both your provider and the echo provider:
```go
import (
"github.com/hashicorp/terraform-plugin-testing/echoprovider"
)
func TestExampleCloudSecret(t *testing.T) {
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_10_0),
},
ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){
"examplecloud": providerserver.NewProtocol5WithError(New()),
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"echo": echoprovider.NewProviderServer(),
},
Steps: []resource.TestStep{
// test configurations
},
})
}
```
### Config Pattern
Pass ephemeral data to the echo provider's `data` attribute, then assert on
the `echo` managed resource:
```terraform
ephemeral "examplecloud_secret" "krb" {
name = "example_kerberos_user"
}
provider "echo" {
data = ephemeral.examplecloud_secret.krb.secret_data
}
resource "echo" "test_krb" {}
```
### State Assertions
Assert on the echo resource's `data` attribute using standard state checks:
```go
Steps: []resource.TestStep{
{
Config: `...`,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("echo.test_krb",
tfjsonpath.New("data").AtMapKey("realm"),
knownvalue.StringExact("EXAMPLE.COM")),
statecheck.ExpectKnownValue("echo.test_krb",
tfjsonpath.New("data").AtMapKey("username"),
knownvalue.StringExact("john-doe")),
statecheck.ExpectKnownValue("echo.test_krb",
tfjsonpath.New("data").AtMapKey("password"),
knownvalue.StringRegexp(regexp.MustCompile(`^.{12}$`))),
},
},
},
```
---
## Multi-Step Testing
The echo resource has special behavior to accommodate ephemeral data
variability:
- During planning for new resources, the `data` attribute is marked unknown
- Existing echo resources preserve prior state regardless of config changes
- Refresh operations always return prior state
Because of this, **create new echo resource instances for each test step**
rather than reusing the same one:
```go
Steps: []resource.TestStep{
{
Config: `
ephemeral "examplecloud_secret" "krb" {
name = "user_one"
}
provider "echo" {
data = ephemeral.examplecloud_secret.krb
}
resource "echo" "test_krb_one" {}
`,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("echo.test_krb_one",
tfjsonpath.New("data").AtMapKey("name"),
knownvalue.StringExact("user_one")),
},
},
{
Config: `
ephemeral "examplecloud_secret" "krb" {
name = "user_two"
}
provider "echo" {
data = ephemeral.examplecloud_secret.krb
}
resource "echo" "test_krb_two" {}
`,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("echo.test_krb_two",
tfjsonpath.New("data").AtMapKey("name"),
knownvalue.StringExact("user_two")),
},
},
},
```

View File

@@ -0,0 +1,101 @@
# Test Sweepers Reference
Sweepers clean up infrastructure resources that leak during acceptance tests —
when test infrastructure fails to be destroyed due to API errors or test
failures.
Source: [Sweepers](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/sweepers)
---
## Setup
### TestMain (required)
Add to a dedicated file (e.g., `sweep_test.go`):
```go
func TestMain(m *testing.M) {
resource.TestMain(m)
}
```
This parses the `-sweep` flag and invokes registered sweepers.
### Register a Sweeper
Register in the test file for the resource being swept, using `init()`:
```go
func init() {
resource.AddTestSweepers("example_widget", &resource.Sweeper{
Name: "example_widget",
F: sweepWidgets,
})
}
func sweepWidgets(region string) error {
client, err := sharedClientForRegion(region)
if err != nil {
return fmt.Errorf("getting client: %w", err)
}
conn := client.(*Client)
widgets, err := conn.ListWidgets()
if err != nil {
return fmt.Errorf("listing widgets: %w", err)
}
for _, w := range widgets {
if !strings.HasPrefix(w.Name, "test-acc") {
continue
}
if err := conn.DeleteWidget(w.ID); err != nil {
log.Printf("[WARN] Failed to delete widget %s: %s", w.ID, err)
}
}
return nil
}
```
Use a consistent test name prefix (e.g., `"test-acc"`) to identify
test-created resources.
### Dependencies
When resources have ordering requirements (e.g., child resources must be
deleted before parents), the **parent** sweeper declares children as
dependencies so they run first:
```go
resource.AddTestSweepers("example_widget", &resource.Sweeper{
Name: "example_widget",
Dependencies: []string{"example_widget_child"},
F: sweepWidgets,
})
```
Dependencies run **before** the sweeper that declares them. In this example,
`example_widget_child` is swept first, then `example_widget`.
### Shared Client
Create a helper to build an API client for the sweep region:
```go
func sharedClientForRegion(region string) (any, error) {
// Build and return a configured API client
return NewClient(region)
}
```
## Running Sweepers
```bash
# Run all sweepers for a region
TF_ACC=1 go test ./internal/service/example -sweep=us-east-1 -v
# Makefile target (common convention)
make sweep
```

View File

@@ -0,0 +1,80 @@
---
name: pulumi-terraform-to-pulumi
description: Migrate Terraform/OpenTofu projects to Pulumi, including translating HCL source code and/or importing Terraform state into a Pulumi stack. Use when a user wants to convert Terraform to Pulumi, migrate from HCL, or import tfstate into Pulumi. Do NOT trigger for general Terraform-vs-Pulumi comparisons or questions about using both tools side-by-side.
---
# Migrating from Terraform to Pulumi
> **Critical constraints — read before acting:**
> - Do NOT run `pulumi convert` — use the terraform-migrate plugin instead, which preserves state mapping.
> - Do NOT run `pulumi package add terraform-module` — this is for a different workflow.
> - Do NOT create the Pulumi project under `/workspace` — create it inside the checked-out repo.
> - Replace `${terraform_dir}` and `${pulumi_dir}` below with the actual paths confirmed with the user.
First establish scope and plan the migration by working out with the user:
- where the Terraform sources are (`${terraform_dir}`)
- where the migrated Pulumi project lives (`${pulumi_dir}`)
- what is the target Pulumi language (such as TypeScript, Python, YAML)
- whether migration aims to setup Pulumi stack states, or only translate source code
Confirm the plan with the user before proceeding.
Create a new Pulumi project in `${pulumi_dir}` in the chosen language. Edit sources to be empty and not declare any
resources. Ensure a Pulumi stack exists.
You must run `pulumi_up` tool before proceeding to ensure initial stack state is written.
If no local `.tfstate` file exists in `${terraform_dir}`, the state may be in a remote backend (S3, Pulumi Cloud, Terraform Cloud, etc.). Pull it before proceeding:
cd ${terraform_dir} && terraform state pull > terraform.tfstate
This works for all backends, including Pulumi Cloud. If `terraform` is not available, try `tofu state pull` instead.
Now produce a draft Pulumi state translation:
pulumi plugin run terraform-migrate -- stack \
--from ${terraform_dir} \
--to ${pulumi_dir} \
--out /tmp/pulumi-state.json \
--plugins /tmp/required-providers.json
Do NOT install the plugin as it will auto-install as needed.
Sometimes terraform-migrate plugin fails because `tofu refresh` is not authorized. DO NOT skip this step. Work with the
user to find or build a Pulumi ESC environment that provides the necessary credentials so the command can succeed. If setting up an ESC environment is not feasible, inform the user that the migration cannot proceed automatically.
Read the generated `/tmp/required-providers.json` and install all these Pulumi providers into the new project,
respecting the suggested versions even if they downgrade an already installed provider. The file will contain records
such as `[{"name":"aws","version":"7.12.0"}]`.
Install providers as project dependencies using the language-specific package manager (NOT `pulumi plugin install`,
which only downloads plugins without adding dependencies):
# TypeScript/JavaScript
npm install @pulumi/aws@7.12.0
# Python
pip install pulumi_aws==7.12.0
# Go
go get github.com/pulumi/pulumi-aws/sdk/v7@v7.12.0
# C#
dotnet add package Pulumi.Aws --version 7.12.0
Import the translated state draft (`/tmp/pulumi-state.json`) into the Pulumi stack:
pulumi stack import --file /tmp/pulumi-state.json
Translate source code to match both the Terraform source and the translated state. Aim for exact match. You can consult
the state draft `/tmp/pulumi-state.json` for Pulumi resource types and names to use.
Iterate on fixing the source code until `pulumi_preview` tool confirms that there are no changes to make and the diff
is empty or almost empty. Provider diffs or diffs on tags may be OK.
Offer the user to link an ESC environment to the stack so that each Pulumi stack can seamlessly have access to the
provider credentials it needs.
When all looks good, create a Pull Request with the migrated source code.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Terraform to Pulumi Migration"
short_description: "Migrate Terraform projects to Pulumi"
default_prompt: "Use $pulumi-terraform-to-pulumi to migrate Terraform infrastructure to Pulumi."

View File

@@ -0,0 +1,26 @@
# Queries that should activate the pulumi-terraform-to-pulumi skill
queries:
# Explicit conversion requests
- "I have a Terraform configuration and want to migrate it to Pulumi"
- "Convert my Terraform code to Pulumi TypeScript"
- "Help me translate this HCL to Pulumi"
- "Can you convert this .tf file to Pulumi?"
- "Translate our Terraform modules to Pulumi components"
- "Our team wants to switch from TF to Pulumi, where do I start?"
# Implicit/contextual (file references, HCL mentions)
- "I have main.tf, variables.tf and outputs.tf - help me convert these"
- "This HCL defines an EKS cluster, I need the Pulumi equivalent"
- "terraform plan shows these resources, how would I create them in Pulumi?"
- "I have these .tf files from our old setup"
- "Our HCL modules define the entire network infrastructure"
# Real-world prompts from users
- "I have a lambda app in AWS provisioned with terraform. I'd like to migrate from Terraform to Pulumi"
- "Please help migrate infra from Terraform to Pulumi by creating a matching Pulumi program"
- "I need to migrate my Terraform state to Pulumi, how do I do that?"
- "Create a pulumi program to match the terraform one and validate it has no diffs"
- "What does the migration process from Terraform to Pulumi look like?"
- "how to convert terraform to pulumi"
- "The code is in tf-to-pulumi-app repo. Migrate the terraform code to pulumi typescript"
- "I'd like to migrate from Terraform to Pulumi. The end result should be a PR with a pulumi program"

View File

@@ -0,0 +1,203 @@
---
name: push-to-registry
description: Push Packer build metadata to HCP Packer registry for tracking and managing image lifecycle. Use when integrating Packer builds with HCP Packer for version control and governance.
---
# Push to HCP Packer Registry
Configure Packer templates to push build metadata to HCP Packer registry.
**Reference:** [HCP Packer Registry](https://developer.hashicorp.com/hcp/docs/packer)
> **Note:** HCP Packer is free for basic use. Builds push metadata only (not actual images), adding minimal overhead (<1 minute).
## Basic Registry Configuration
```hcl
packer {
required_version = ">= 1.7.7"
}
variable "image_name" {
type = string
default = "web-server"
}
locals {
timestamp = regex_replace(timestamp(), "[- TZ:]", "")
}
source "amazon-ebs" "ubuntu" {
region = "us-west-2"
instance_type = "t3.micro"
source_ami_filter {
filters = {
name = "ubuntu/images/*ubuntu-jammy-22.04-amd64-server-*"
}
most_recent = true
owners = ["099720109477"]
}
ssh_username = "ubuntu"
ami_name = "${var.image_name}-${local.timestamp}"
}
build {
sources = ["source.amazon-ebs.ubuntu"]
hcp_packer_registry {
bucket_name = var.image_name
description = "Ubuntu 22.04 base image for web servers"
bucket_labels = {
"os" = "ubuntu"
"team" = "platform"
}
build_labels = {
"build-time" = local.timestamp
}
}
provisioner "shell" {
inline = [
"sudo apt-get update",
"sudo apt-get upgrade -y",
]
}
}
```
## Authentication
Set environment variables before building:
```bash
export HCP_CLIENT_ID="your-service-principal-client-id"
export HCP_CLIENT_SECRET="your-service-principal-secret"
export HCP_ORGANIZATION_ID="your-org-id"
export HCP_PROJECT_ID="your-project-id"
packer build .
```
### Create HCP Service Principal
1. Navigate to HCP → Access Control (IAM)
2. Create Service Principal
3. Grant "Contributor" role on project
4. Generate client secret
5. Save client ID and secret
## Registry Configuration Options
### bucket_name (required)
The image identifier. Must stay consistent across builds!
```hcl
bucket_name = "web-server" # Keep this constant
```
### bucket_labels (optional)
Metadata at bucket level. Updates with each build.
```hcl
bucket_labels = {
"os" = "ubuntu"
"team" = "platform"
"component" = "web"
}
```
### build_labels (optional)
Metadata for each iteration. Immutable after build completes.
```hcl
build_labels = {
"build-time" = local.timestamp
"git-commit" = var.git_commit
}
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Build and Push to HCP Packer
on:
push:
branches: [main]
env:
HCP_CLIENT_ID: ${{ secrets.HCP_CLIENT_ID }}
HCP_CLIENT_SECRET: ${{ secrets.HCP_CLIENT_SECRET }}
HCP_ORGANIZATION_ID: ${{ secrets.HCP_ORGANIZATION_ID }}
HCP_PROJECT_ID: ${{ secrets.HCP_PROJECT_ID }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-packer@main
- name: Build and push
run: |
packer init .
packer build \
-var "git_commit=${{ github.sha }}" \
.
```
## Querying in Terraform
```hcl
data "hcp_packer_artifact" "ubuntu" {
bucket_name = "web-server"
channel_name = "production"
platform = "aws"
region = "us-west-2"
}
resource "aws_instance" "web" {
ami = data.hcp_packer_artifact.ubuntu.external_identifier
instance_type = "t3.micro"
tags = {
PackerBucket = data.hcp_packer_artifact.ubuntu.bucket_name
}
}
```
## Common Issues
**Authentication Failed**
- Verify HCP_CLIENT_ID and HCP_CLIENT_SECRET
- Ensure service principal has Contributor role
- Check organization and project IDs
**Bucket Name Mismatch**
- Keep `bucket_name` consistent across builds
- Don't include timestamps in bucket_name
- Creates new bucket if name changes
**Build Fails**
- Packer fails immediately if can't push metadata
- Prevents drift between artifacts and registry
- Check network connectivity to HCP API
## Best Practices
- **Consistent bucket names** - Never change for same image type
- **Meaningful labels** - Use for versions, teams, compliance
- **CI/CD automation** - Automate builds and registry pushes
- **Immutable build labels** - Put changing data (git SHA, date) in build_labels
## References
- [HCP Packer Documentation](https://developer.hashicorp.com/hcp/docs/packer)
- [hcp_packer_registry Block](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/blocks/build/hcp_packer_registry)
- [HCP Terraform Provider](https://registry.terraform.io/providers/hashicorp/hcp/latest/docs/data-sources/packer_artifact)

View File

@@ -0,0 +1,538 @@
---
name: refactor-module
description: Transform monolithic Terraform configurations into reusable, maintainable modules following HashiCorp's module design principles and community best practices.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Skill: Refactor Module
## Overview
This skill guides AI agents in transforming monolithic Terraform configurations into reusable, maintainable modules following HashiCorp's module design principles and community best practices.
## Capability Statement
The agent will analyze existing Terraform code and systematically refactor it into well-structured modules with:
- Clear interface contracts (variables and outputs)
- Proper encapsulation and abstraction
- Versioning and documentation
- Testing frameworks
- Migration path for existing state
## Prerequisites
- Existing Terraform configuration to refactor
- Understanding of resource dependencies
- Access to current state file (for migration planning)
- Knowledge of module registry patterns
## Input Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `source_directory` | string | Yes | Path to existing Terraform configuration |
| `module_name` | string | Yes | Name for the new module |
| `abstraction_level` | string | No | "simple", "intermediate", "advanced" (default: intermediate) |
| `preserve_state` | boolean | Yes | Whether to maintain state compatibility |
| `target_registry` | string | No | Target module registry (local, private, public) |
## Execution Steps
### 1. Analysis Phase
```markdown
**Identify Refactoring Candidates**
- Group resources by logical function
- Identify repeated patterns
- Map resource dependencies
- Detect configuration coupling
- Analyze variable usage patterns
**Complexity Assessment**
- Count resource relationships
- Measure variable propagation depth
- Identify cross-resource references
- Evaluate state migration complexity
```
### 2. Module Design
#### Interface Design
```hcl
# Define clear input contract
variable "network_config" {
description = "Network configuration parameters"
type = object({
cidr_block = string
availability_zones = list(string)
enable_nat = bool
})
validation {
condition = can(cidrhost(var.network_config.cidr_block, 0))
error_message = "CIDR block must be valid IPv4 CIDR."
}
}
# Define output contract
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = { for k, v in aws_subnet.private : k => v.id }
}
```
#### Encapsulation Strategy
```markdown
**What to Include in Module:**
- Tightly coupled resources (VPC + subnets)
- Resources with shared lifecycle
- Configuration with clear boundaries
**What to Keep Separate:**
- Cross-cutting concerns (monitoring, tagging)
- Resources with different lifecycles
- Provider-specific configurations
```
### 3. Code Transformation
#### Before: Monolithic Configuration
```hcl
# main.tf (monolithic)
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "production-vpc"
Environment = "prod"
}
}
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
tags = {
Name = "public-subnet-1"
Type = "public"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
tags = {
Name = "public-subnet-2"
Type = "public"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "production-igw"
}
}
# ... more repetitive subnet and routing resources
```
#### After: Modular Structure
```hcl
# modules/vpc/main.tf
locals {
subnet_count = length(var.availability_zones)
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
var.tags,
{
Name = var.name
}
)
}
resource "aws_subnet" "public" {
for_each = var.create_public_subnets ? toset(var.availability_zones) : []
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, index(var.availability_zones, each.value))
availability_zone = each.value
map_public_ip_on_launch = true
tags = merge(
var.tags,
{
Name = "${var.name}-public-${each.value}"
Type = "public"
}
)
}
resource "aws_internet_gateway" "main" {
count = var.create_public_subnets ? 1 : 0
vpc_id = aws_vpc.main.id
tags = merge(
var.tags,
{
Name = "${var.name}-igw"
}
)
}
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all resources"
type = string
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid IPv4 CIDR block."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "create_public_subnets" {
description = "Whether to create public subnets"
type = bool
default = true
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in the VPC"
type = bool
default = true
}
variable "enable_dns_support" {
description = "Enable DNS support in the VPC"
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "Map of availability zones to public subnet IDs"
value = { for k, v in aws_subnet.public : k => v.id }
}
output "internet_gateway_id" {
description = "ID of the internet gateway"
value = try(aws_internet_gateway.main[0].id, null)
}
# Root configuration using module
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = {
Environment = "production"
ManagedBy = "Terraform"
}
}
```
### 4. State Migration
#### Generate Migration Plan
```hcl
# migration.tf
# Use moved blocks for state refactoring (Terraform 1.1+)
moved {
from = aws_vpc.main
to = module.vpc.aws_vpc.main
}
moved {
from = aws_subnet.public_1
to = module.vpc.aws_subnet.public["us-east-1a"]
}
moved {
from = aws_subnet.public_2
to = module.vpc.aws_subnet.public["us-east-1b"]
}
moved {
from = aws_internet_gateway.main
to = module.vpc.aws_internet_gateway.main[0]
}
```
#### Manual State Migration (Pre-1.1)
```bash
# Generate state migration commands
terraform state mv aws_vpc.main module.vpc.aws_vpc.main
terraform state mv aws_subnet.public_1 'module.vpc.aws_subnet.public["us-east-1a"]'
terraform state mv aws_subnet.public_2 'module.vpc.aws_subnet.public["us-east-1b"]'
terraform state mv aws_internet_gateway.main 'module.vpc.aws_internet_gateway.main[0]'
```
### 5. Module Documentation
```markdown
# VPC Module
## Overview
Creates a VPC with configurable public and private subnets across multiple availability zones.
## Features
- Multi-AZ subnet deployment
- Optional NAT gateway configuration
- VPC Flow Logs integration
- Customizable CIDR allocation
## Usage
\`\`\`hcl
module "vpc" {
source = "./modules/vpc"
name = "my-vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
create_public_subnets = true
create_private_subnets = true
enable_nat_gateway = true
tags = {
Environment = "production"
}
}
\`\`\`
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.5.0 |
| aws | ~> 5.0 |
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name | Name prefix for resources | `string` | n/a | yes |
| cidr_block | VPC CIDR block | `string` | n/a | yes |
| availability_zones | List of AZs | `list(string)` | n/a | yes |
## Outputs
| Name | Description |
|------|-------------|
| vpc_id | VPC identifier |
| public_subnet_ids | Map of public subnet IDs |
| private_subnet_ids | Map of private subnet IDs |
## Examples
See [examples/](./examples/) directory for complete usage examples.
```
### 6. Testing
Use skill terraform-test
**Test File**: A `.tftest.hcl` or `.tftest.json` file containing test configuration and run blocks that validate your Terraform configuration.
**Test Block**: Optional configuration block that defines test-wide settings (available since Terraform 1.6.0).
**Run Block**: Defines a single test scenario with optional variables, provider configurations, and assertions. Each test file requires at least one run block.
**Assert Block**: Contains conditions that must evaluate to true for the test to pass. Failed assertions cause the test to fail.
**Mock Provider**: Simulates provider behavior without creating real infrastructure (available since Terraform 1.7.0).
**Test Modes**: Tests run in apply mode (default, creates real infrastructure) or plan mode (validates logic without creating resources).
#### File Structure
Terraform test files use the `.tftest.hcl` or `.tftest.json` extension and are typically organized in a `tests/` directory. Use clear naming conventions to distinguish between unit tests (plan mode) and integration tests (apply mode):
```
my-module/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
├── unit_test.tftest.hcl # Unit test (plan mode)
└── integration_test.tftest.hcl # Integration test (apply mode - creates real resources)
```
## Refactoring Patterns
### Pattern 1: Resource Grouping
Extract related resources into cohesive modules:
- Networking (VPC, Subnets, Route Tables)
- Compute (ASG, Launch Templates, Load Balancers)
- Data (RDS, ElastiCache, S3)
### Pattern 2: Configuration Layering
```hcl
# Base module with defaults
module "vpc_base" {
source = "./modules/vpc-base"
# Minimal required inputs
}
# Environment-specific wrapper
module "vpc_prod" {
source = "./modules/vpc-production"
# Inherits from base, adds prod-specific config
}
```
### Pattern 3: Composition
```hcl
# Small, focused modules
module "vpc" {
source = "./modules/vpc"
}
module "security_groups" {
source = "./modules/security-groups"
vpc_id = module.vpc.vpc_id
}
module "application" {
source = "./modules/application"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
sg_ids = module.security_groups.app_sg_ids
}
```
## Common Pitfalls
### 1. Over-Abstraction
```hcl
# ❌ Don't create overly generic modules
variable "resources" {
type = map(map(any)) # Too flexible, hard to validate
}
# ✅ Do use specific, typed interfaces
variable "database_config" {
type = object({
engine = string
instance_class = string
})
}
```
### 2. Tight Coupling
```hcl
# ❌ Don't couple modules through direct references
# module A
output "instance_id" { value = aws_instance.app.id }
# module B (in same config)
resource "aws_eip" "app" {
instance = module.a.instance_id # Tight coupling
}
# ✅ Do pass dependencies through root module
module "compute" {
source = "./modules/compute"
}
resource "aws_eip" "app" {
instance = module.compute.instance_id
}
```
### 3. State Migration Errors
Always test migration in non-production first:
```bash
# Create plan to verify no changes after migration
terraform plan -out=migration.tfplan
# Review carefully
terraform show migration.tfplan
# Apply only if plan shows no changes
terraform apply migration.tfplan
```
## Version Control Strategy
```hcl
# Use semantic versioning for modules
module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
version = "~> 1.2"
}
# Pin to specific versions in production
# Use version ranges in development
```
## Success Criteria
- [ ] Module has single, well-defined responsibility
- [ ] All variables have descriptions and types
- [ ] Validation rules prevent invalid configurations
- [ ] Outputs provide sufficient information for consumers
- [ ] Documentation includes usage examples
- [ ] Tests verify module behavior
- [ ] State migration completed without resource recreation
- [ ] No plan differences after refactoring
## Related Skills
- [Terraform code generation](https://raw.githubusercontent.com/hashicorp/agent-skills/refs/heads/main/terraform/code-generation/skills/terraform-style-guide/SKILL.md) - Style guide for the new Terraform Module
- [Azure Verified Modules](https://raw.githubusercontent.com/hashicorp/agent-skills/refs/heads/main/terraform/code-generation/skills/azure-verified-modules/SKILL.md) - Recommended module specifications for Azure
## Resources
- [Terraform Module Development](https://developer.hashicorp.com/terraform/language/modules/develop)
- [Module Best Practices](https://developer.hashicorp.com/terraform/cloud-docs/registry/design)
## Revision History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2025-11-07 | Initial skill definition |

View File

@@ -0,0 +1,41 @@
---
name: run-acceptance-tests
description: Guide for running acceptance tests for a Terraform provider. Use this when asked to run an acceptance test or to run a test with the prefix `TestAcc`.
license: MPL-2.0
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
An acceptance test is a Go test function with the prefix `TestAcc`.
To run a focussed acceptance test named `TestAccFeatureHappyPath`:
1. Run `go test -run=TestAccFeatureHappyPath` with the following environment
variables:
- `TF_ACC=1`
Default to non-verbose test output.
1. The acceptance tests may require additional environment variables for
specific providers. If the test output indicates missing environment
variables, then suggest how to set up these environment variables securely.
To diagnose a failing acceptance test, use these options, in order. These
options are cumulative: each option includes all the options above it.
1. Run the test again. Use the `-count=1` option to ensure that `go test` does
not use a cached result.
1. Offer verbose `go test` output. Use the `-v` option.
1. Offer debug-level logging. Enable debug-level logging with the environment
variable `TF_LOG=debug`.
1. Offer to persist the acceptance test's Terraform workspace. Enable
persistance with the environment variable `TF_ACC_WORKING_DIR_PERSIST=1`.
A passing acceptance test may be a false negative. To "flip" a passing
acceptance test named `TestAccFeatureHappyPath`:
1. Edit the value of one of the TestCheckFuncs in one of the TestSteps in the
TestCase.
1. Run the acceptance test. Expect the test to fail.
1. If the test fails, then undo the edit and report a successful flip. Else,
keep the edit and report an unsuccessful flip.

View File

@@ -0,0 +1,3 @@
.DS_Store
__pycache__/
*.pyc

View File

@@ -0,0 +1,41 @@
# Terraform Policy Agent Skills
A family of focused agent skills for working with [Terraform Policy](https://developer.hashicorp.com/terraform/cloud-docs/policy-enforcement) — HCP Terraform's native policy-as-code engine for `.policy.hcl` and `.policytest.hcl` files.
## Routing
Pick the skill that matches the user's journey:
| Journey | Reference |
| --- | --- |
| Write a new Terraform Policy from an English description | [**tfpolicy-author**](references/tfpolicy-author.md) |
| Translate Sentinel (or adjacent OPA/Rego) to Terraform Policy | [**tfpolicy-author**](references/tfpolicy-author.md) |
| Write or debug a `.policytest.hcl` test, mock resources, reason about the runner | [**tfpolicy-test**](references/tfpolicy-test.md) |
## Repository layout
```
terraform-policy/
├── SKILL.md # Router — routes to references below
├── references/
│ ├── tfpolicy-author.md # Authoring + Sentinel conversion (v0.2.0)
│ ├── tfpolicy-test.md # Testing + full testing guide
│ └── verified-syntax.md # Shared source-of-truth syntax reference
├── examples/
│ └── conversion/ # Side-by-side .sentinel / .policy.hcl examples
└── evals/
├── eval.yaml
└── tasks/
```
## Shared reference
[`references/verified-syntax.md`](references/verified-syntax.md) is the single source of truth for verified Terraform Policy syntax, function names, and runtime limitations. All reference files link to it rather than duplicating facts — when reference content disagrees with this file, the reference wins.
## Versioning
Each reference is versioned independently via its `metadata.version` field.
## License
MPL-2.0. Copyright IBM Corp. 2026.

View File

@@ -0,0 +1,46 @@
---
name: terraform-policy
description: "Write, test, or convert Terraform Policy files (.policy.hcl, .policytest.hcl, Sentinel→tfpolicy). Triggers: policy.hcl, policytest, convert sentinel, tfpolicy, write a policy."
license: MPL-2.0
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.1.0"
---
# terraform-policy
**UTILITY SKILL** — INVOKES: [tfpolicy-author](references/tfpolicy-author.md) | [tfpolicy-test](references/tfpolicy-test.md)
## USE FOR:
- Writing a new `.policy.hcl` policy from a description or requirement
- Converting a `.sentinel` policy to Terraform Policy
- Writing or debugging a `.policytest.hcl` test file
- Migrating a Sentinel policy library to Terraform Policy
## DO NOT USE FOR:
- Writing `.tftest.hcl` files for Terraform modules — use `terraform-test`
- General Terraform HCL authoring — use `terraform-style-guide`
## Routing
| Task | Sub-skill |
|------|-----------|
| Write or convert a `.policy.hcl` policy | [tfpolicy-author](references/tfpolicy-author.md) |
| Write or debug a `.policytest.hcl` test | [tfpolicy-test](references/tfpolicy-test.md) |
## Examples
- "Block EC2 instances without encryption" → [tfpolicy-author](references/tfpolicy-author.md)
- "Convert this Sentinel policy to tfpolicy" → [tfpolicy-author](references/tfpolicy-author.md)
- "Write a policytest for my EBS policy" → [tfpolicy-test](references/tfpolicy-test.md)
## Troubleshooting
- **Wrong skill triggered?** Load the sub-skill directly from the routing table above.
```bash
npx skills add hashicorp/agent-skills/terraform/terraform-policy/skills/tfpolicy-author
npx skills add hashicorp/agent-skills/terraform/terraform-policy/skills/tfpolicy-test
```

View File

@@ -0,0 +1,27 @@
name: terraform-policy-eval
description: Auto-generated eval for terraform-policy.
skill: terraform-policy
version: "1.0"
config:
trials_per_task: 1
timeout_seconds: 300
parallel: false
executor: copilot-sdk
model: claude-sonnet-4.6
metrics:
- name: task_completion
weight: 0.7
threshold: 0.8
description: Did the skill complete trigger and anti-trigger checks?
- name: efficiency
weight: 0.3
threshold: 0.7
description: Did the skill stay within behavior limits?
graders:
- type: behavior
name: token-budget
config:
max_tokens: 35000
tasks:
- "tasks/*.yaml"

View File

@@ -0,0 +1,17 @@
id: negative-trigger-001
name: Negative Trigger 1
description: Auto-generated negative-trigger task.
tags:
- negative-trigger
inputs:
prompt: "Tell me a short joke about coffee."
expected:
should_trigger: false
graders:
- type: text
name: omits-skill-keywords
config:
not_contains:
- "policy"
- "terraform"

View File

@@ -0,0 +1,17 @@
id: positive-trigger-001
name: Positive Trigger 1
description: Auto-generated positive-trigger task.
tags:
- positive-trigger
inputs:
prompt: "Use terraform-policy to help me complete this task"
expected:
should_trigger: true
graders:
- type: text
name: contains-keywords
config:
contains:
- "policy"
- "terraform"

View File

@@ -0,0 +1,17 @@
id: positive-trigger-002
name: Positive Trigger 2
description: Auto-generated positive-trigger task.
tags:
- positive-trigger
inputs:
prompt: "I need assistance with terraform policy-related work"
expected:
should_trigger: true
graders:
- type: text
name: contains-keywords
config:
contains:
- "policy"
- "terraform"

View File

@@ -0,0 +1,30 @@
# Sentinel to tfpolicy Conversion Examples
This folder packages representative Sentinel-to-tfpolicy conversion examples for sharing with teammates.
Each example subfolder contains:
- `<sentinel-policy-name>.sentinel` - the actual Sentinel policy file included for comparison
- `<sentinel-policy-name>.policy.hcl` - the tfpolicy version or best approximation
- `README.md` - explanation of the conversion quality, what changed, and any limitations
Converted tfpolicy examples in this bundle prefer remediation-focused diagnostics over repeating Terraform addresses from Sentinel `summary {}` output. Terraform Policy diagnostics already identify the failing object and point to the relevant location, so converted examples avoid `${meta.address}` in error messages.
Included examples:
- `dms-endpoints-should-use-ssl` - direct attribute conversion (`Perfect`)
- `elasticsearch-https-required` - nested block conversion (`Good`)
- `eventbridge-custom-event-bus-should-have-attached-policy` - cross-resource conversion via `core::getresources()` (`Limited`)
- `cloudfront-associated-with-waf` - approximation only due to missing reference metadata (`Not convertible` as an exact translation)
- `efs-access-point-should-enforce-user-identity` - direct presence check (`Perfect`)
- `elasticsearch-encrypted-at-rest` - nested encryption block check (`Good`)
- `dms-endpoint-should-be-ssl-configured` - config-derived certificate check (`Good`)
- `ec2-network-acl-should-have-subnet-ids` - association-aware approximation (`Limited`)
- `secretsmanager-auto-rotation-enabled-check` - secret-to-rotation relationship via `core::getresources()` (`Good`)
- `s3-bucket-should-have-object-lock-enabled` - object lock association approximation (`Limited`)
- `ec2-vpc-default-security-group-no-traffic` - inline-only approximation of a broader graph check (`Not convertible` as an exact translation)
- `elasticsearch-in-vpc-only` - config-to-end-state VPC placement approximation (`Limited`)
- `cloudtrail-server-side-encryption-enabled` - config-to-end-state encryption check (`Good`)
- `step-functions-state-machine-logging-enabled` - nested logging block conversion (`Good`)
- `elasticache-redis-replication-group-encryption-at-transit-enabled` - direct boolean check (`Perfect`)
- `s3-block-public-access-bucket-level` - variable and association heavy approximation (`Not convertible` as an exact translation)
Note: The Sentinel policy files in this bundle come from the locally cloned policy library so reviewers can inspect the original Sentinel and converted tfpolicy side by side in one place.

View File

@@ -0,0 +1,19 @@
# CloudFront Associated with WAF
## Source Sentinel Policy
`cloudfront-associated-with-waf.sentinel`
## Conversion Quality
`Not convertible` as an exact translation
## What the approximation does
The included tfpolicy approximation checks only that `web_acl_id` is set to a non-empty value on `aws_cloudfront_distribution` resources.
## Why exact conversion is not possible today
The Sentinel policy uses `tfconfig/v2` plus reference metadata (`references`) to reason about whether the CloudFront distribution is associated with a WAF resource. Current tfpolicy guidance does not expose equivalent reference metadata, so it cannot distinguish:
- literal values
- references to WAF resources
- computed values
## Key limitation
This means tfpolicy can enforce presence of a `web_acl_id`, but it cannot safely reproduce the Sentinel policy's reference-aware behavior.

View File

@@ -0,0 +1,14 @@
# Approximation of HashiCorp PCI DSS Sentinel example: cloudfront-associated-with-waf.sentinel
# Exact conversion quality: Not convertible
# This tfpolicy only checks for a non-empty web_acl_id value.
resource_policy "aws_cloudfront_distribution" "require_web_acl_id" {
locals {
web_acl_id = core::try(attrs.web_acl_id, "")
}
enforce {
condition = local.web_acl_id != ""
error_message = "CloudFront distributions should set web_acl_id to associate a WAF or WAF Classic ACL"
}
}

View File

@@ -0,0 +1,61 @@
// This policy checks whether 'aws_cloudfront_distribution' are associated with either AWS WAF Classic or AWS WAF web ACLs.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
// Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
// Constants
const = {
"policy_name": "cloudfront-associated-with-waf",
"message": "'aws_cloudfront_distribution' are associated with either AWS WAF Classic or AWS WAF web ACLs. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-6 for more details.",
"resource_aws_cloudfront_distribution": "aws_cloudfront_distribution",
}
// Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
web_acl_id = maps.get(res.config, "web_acl_id", {})
if web_acl_id is null or web_acl_id is empty {
return false
}
references = maps.get(web_acl_id, "references", [])
return references is not empty
})
}
// Variables
config_resources = tf.config(tfconfig.resources)
cloudfront_distribution_resource = config_resources.type(const.resource_aws_cloudfront_distribution).resources
violations = get_violations(cloudfront_distribution_resource)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
// Outputs
print(report.generate_policy_report(summary))
// Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,17 @@
# CloudTrail Server-Side Encryption Enabled
## Source Sentinel Policy
`cloudtrail-server-side-encryption-enabled.sentinel`
## Conversion Quality
`Good`
## Why this is Good
The Sentinel policy is config-oriented and checks whether `kms_key_id` is present as a configured value. tfpolicy can preserve the same enforcement intent by validating the planned end-state value for `attrs.kms_key_id`.
## Key translation notes
- `tfconfig/v2` config inspection becomes a planned-value check in tfpolicy
- The converted policy focuses on whether `kms_key_id` is ultimately present, not whether it originated as a constant in the config
## Limitations encountered
The tfpolicy version does not preserve the config-level distinction between explicit constant values and other configuration forms. It validates the final planned attribute value instead.

View File

@@ -0,0 +1,13 @@
# Converted from HashiCorp PCI DSS Sentinel example: cloudtrail-server-side-encryption-enabled.sentinel
# Conversion quality: Good
resource_policy "aws_cloudtrail" "cloudtrail_server_side_encryption_enabled" {
locals {
kms_key_id = core::try(attrs.kms_key_id, "")
}
enforce {
condition = local.kms_key_id != ""
error_message = "CloudTrail resources must set kms_key_id for server-side encryption"
}
}

View File

@@ -0,0 +1,53 @@
# This policy requires that resources of type `aws_cloudtrail` have server-side encryption enabled.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"resource_aws_cloudtrail": "aws_cloudtrail",
"policy_name": "cloudtrail-server-side-encryption-enabled",
"message": "Attribute 'kms_key_id' must be present for 'aws_cloudtrail' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/cloudtrail-controls.html#cloudtrail-2 for more details.",
"cloudtrail_attribute_kms_key_id": "kms_key_id",
"constant_value": "constant_value",
}
# Variables
resources = tf.config(tfconfig.resources).type(const.resource_aws_cloudtrail).resources
violations = collection.reject(resources, func(res) {
key_path = "config.kms_key_id"
return maps.get(res, key_path, false) is not false and
maps.get(res, key_path + "." + const.constant_value, false) is not ""
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,17 @@
# DMS Endpoint Should Be SSL Configured
## Source Sentinel Policy
`dms-endpoint-should-be-ssl-configured.sentinel`
## Conversion Quality
`Good`
## Why this converts reasonably well
The Sentinel version uses `tfconfig/v2` to accept either a constant value or a reference for `certificate_arn`. tfpolicy cannot inspect Terraform config reference metadata the same way, but it can still validate that the planned `certificate_arn` value is non-empty.
## Key translation notes
- Config-oriented Sentinel checks become an end-state tfpolicy check on `attrs.certificate_arn`
- tfpolicy focuses on the resulting planned value instead of whether it came from a literal or a reference
## Limitations encountered
The tfpolicy version does not preserve the source-level distinction between constant values and references. It only checks that the final planned value is present.

View File

@@ -0,0 +1,13 @@
# Converted from HashiCorp PCI DSS Sentinel example: dms-endpoint-should-be-ssl-configured.sentinel
# Conversion quality: Good
resource_policy "aws_dms_endpoint" "dms_endpoint_should_be_ssl_configured" {
locals {
certificate_arn = core::try(attrs.certificate_arn, "")
}
enforce {
condition = local.certificate_arn != ""
error_message = "DMS endpoints should set certificate_arn for SSL configuration"
}
}

View File

@@ -0,0 +1,55 @@
# This policy checks if resources of type 'aws_dms_endpoint' have the 'certificate_arn'
# shouldn't be empty
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "dms-endpoint-should-be-ssl-configured",
"message": "Attribute 'certificate_arn' shouldn't be empty for AWS DMS Endpoint. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/dms-controls.html#dms-9 for more details.",
"resource_aws_dms_endpoint": "aws_dms_endpoint",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
certificate_arn_values = maps.get(res, "config.certificate_arn", "")
if certificate_arn_values is empty {
return false
}
return maps.get(certificate_arn_values, "constant_value", "") is not empty or maps.get(certificate_arn_values, "references", "") is not empty
})
}
# Variables
dms_endpoint_resource = tf.config(tfconfig.resources).type(const.resource_aws_dms_endpoint).resources
violations = get_violations(dms_endpoint_resource)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# DMS Endpoint SSL Mode
## Source Sentinel Policy
`dms-endpoints-should-use-ssl.sentinel`
## Conversion Quality
`Perfect`
## Why it converts well
This policy is a straightforward single-resource attribute check. The Sentinel version iterates over `aws_dms_endpoint` resources and rejects any resource whose `ssl_mode` is not in an allowlist. tfpolicy can express the same intent directly with one `resource_policy`, one allowlist, and one `enforce` block.
## Key translation notes
- Sentinel `collection.reject()` becomes one positive `condition`
- `maps.get(res, "values.ssl_mode", null)` becomes `core::try(attrs.ssl_mode, "")`
- No cross-resource logic, state inspection, or reference metadata is involved
## Limitations encountered
No significant tfpolicy limitation blocks this conversion.

View File

@@ -0,0 +1,14 @@
# Converted from HashiCorp PCI DSS Sentinel example: dms-endpoints-should-use-ssl.sentinel
# Conversion quality: Perfect
resource_policy "aws_dms_endpoint" "require_ssl_mode" {
locals {
ssl_mode = core::try(attrs.ssl_mode, "")
valid_ssl_modes = ["require", "verify-ca", "verify-full"]
}
enforce {
condition = core::contains(local.valid_ssl_modes, local.ssl_mode)
error_message = "DMS endpoints must set ssl_mode to one of: require, verify-ca, verify-full"
}
}

View File

@@ -0,0 +1,50 @@
# This policy requires resources of type `aws_dms_endpoint` have attribute "ssl_mode" set to one of: require, verify-ca, verify-full.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "dms-ssl-enabled",
"message": "Attribute 'ssl_mode' must be set to one of: require, verify-ca, verify-full for 'aws_dms_endpoint' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/dms-controls.html#dms-9 for more details.",
"resource_aws_dms_endpoint": "aws_dms_endpoint",
"ssl_mode": "ssl_mode",
"valid_ssl_modes": ["require", "verify-ca", "verify-full"],
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_dms_endpoint).resources
violations = collection.reject(resources, func(res) {
return maps.get(res, "values." + const.ssl_mode, null) in const.valid_ssl_modes
})
summary = {
"policy_name": "dms-ssl-enabled",
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,20 @@
# EC2 Network ACL Should Have Subnet IDs
## Source Sentinel Policy
`ec2-network-acl-should-have-subnet-ids.sentinel`
## Conversion Quality
`Limited`
## Why this is limited
The Sentinel policy uses `tfconfig/v2`, reference metadata, and module-aware address reconstruction to determine whether a network ACL is connected through `aws_network_acl_association`. Current tfpolicy guidance does not expose equivalent reference metadata, so an exact translation is not possible.
## What the approximation does
The tfpolicy version checks either:
- `subnet_ids` is present directly on the network ACL, or
- a matching `aws_network_acl_association` can be found via `core::getresources()` and a value-based lookup
## Limitations encountered
- This is value matching, not true Terraform graph reasoning
- It may behave differently for newly created resources with unresolved values
- It does not reproduce the Sentinel policy's module-aware reference reconstruction exactly

View File

@@ -0,0 +1,24 @@
# Approximation of HashiCorp PCI DSS Sentinel example: ec2-network-acl-should-have-subnet-ids.sentinel
# Exact conversion quality: Limited
locals {
all_network_acl_associations = core::getresources("aws_network_acl_association", {})
associated_network_acl_ids = {
for association in local.all_network_acl_associations :
core::try(association.network_acl_id, "") => true
}
}
resource_policy "aws_network_acl" "network_acl_should_have_subnet_ids" {
locals {
subnet_ids = core::try(attrs.subnet_ids, [])
has_subnet_ids = core::length(local.subnet_ids) > 0
network_acl_id = core::try(attrs.id, "")
has_association = core::try(local.associated_network_acl_ids[local.network_acl_id], false)
}
enforce {
condition = local.has_subnet_ids || local.has_association
error_message = "Network ACLs should define subnet_ids directly or have a matching aws_network_acl_association"
}
}

View File

@@ -0,0 +1,91 @@
// This policy requires `aws_network_acl` resources to have 'subnet_ids' present.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
// Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
// Constants
const = {
"policy_name": "ec2-network-acl-should-have-subnet-ids",
"message": "Attribute 'subnet_ids' must be present for 'aws_network_acl' resources or it should include 'subnet_ids' through 'aws_network_acl_association'. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-16 for more details.",
"resource_aws_network_acl": "aws_network_acl",
"resource_aws_network_acl_association": "aws_network_acl_association",
"subnet_ids": "subnet_ids",
"constant_value": "constant_value",
"module_prefix": "module.",
}
// Functions
get_violations = func(network_acl_resources, network_acl_association_resources) {
return collection.reject(network_acl_resources, func(res) {
subnet_id_values = maps.get(res, "config." + const.subnet_ids, [])
if (subnet_id_values is empty or subnet_id_values.constant_value is defined) and check_network_acl_association(res.address, network_acl_association_resources) {
return false
}
return true
})
}
check_network_acl_association = func(address, network_acl_association_resources) {
if network_acl_association_resources is empty {
return true
}
return collection.find(network_acl_association_resources, func(res) {
network_acl_id_reference = get_referenced_resource_address(res, "config.network_acl_id")
if network_acl_id_reference is empty {
return false
}
return address is network_acl_id_reference
}) is not defined
}
get_referenced_resource_address = func(res, attr) {
references_list = maps.get(res, attr, [])
if references_list.references is empty {
return ""
}
referenced_address = references_list.references[1]
if strings.has_prefix(res.address, const.module_prefix) {
referenced_address = res.module_address + "." + referenced_address
}
return referenced_address
}
// Variables
config_resources = tf.config(tfconfig.resources)
network_acl_resources = config_resources.type(const.resource_aws_network_acl).resources
network_acl_association_resources = config_resources.type(const.resource_aws_network_acl_association).resources
violations = get_violations(network_acl_resources, network_acl_association_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
// Outputs
print(report.generate_policy_report(summary))
// Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,22 @@
# EC2 VPC Default Security Group No Traffic
## Source Sentinel Policy
`ec2-vpc-default-security-group-no-traffic.sentinel`
## Conversion Quality
`Not convertible` as an exact translation
## What the approximation does
The included tfpolicy checks only inline `ingress` and `egress` rules on `aws_default_security_group` resources.
## Why exact conversion is not possible today
The Sentinel policy combines several config-level resource types:
- `aws_default_security_group`
- `aws_security_group_rule`
- `aws_vpc_security_group_ingress_rule`
- `aws_vpc_security_group_egress_rule`
It then uses `tfconfig/v2` reference metadata and regex checks to determine whether those separate rule resources target the default security group of a VPC. Current tfpolicy guidance does not expose equivalent config graph metadata, so it cannot safely reproduce that full relationship-aware behavior.
## Key limitation
This means tfpolicy can approximate the inline-rule case, but it cannot fully enforce the broader Sentinel policy that also reasons over separate security group rule resources attached by reference.

View File

@@ -0,0 +1,20 @@
# Approximation of HashiCorp PCI DSS Sentinel example: ec2-vpc-default-security-group-no-traffic.sentinel
# Exact conversion quality: Not convertible
# This tfpolicy only checks inline ingress/egress on aws_default_security_group resources.
resource_policy "aws_default_security_group" "ec2_vpc_default_security_group_no_traffic" {
locals {
ingress_rules = core::try(attrs.ingress, [])
egress_rules = core::try(attrs.egress, [])
}
enforce {
condition = core::length(local.ingress_rules) == 0
error_message = "Default security groups should not allow inline ingress traffic"
}
enforce {
condition = core::length(local.egress_rules) == 0
error_message = "Default security groups should not allow inline egress traffic"
}
}

View File

@@ -0,0 +1,94 @@
# This policy requires resources of type `aws_vpc` to have no traffic for default security group.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"message": "VPC default security group should not allow inbound and outbound traffic. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-2 for more details.",
"policy_name": "ec2-vpc-default-security-group-no-traffic",
"config": "config",
"security_group_id": "security_group_id",
"references": "references",
"constant_value": "constant_value",
"resource_aws_default_security_group": "aws_default_security_group",
"ingress": "ingress",
"egress": "egress",
"resource_aws_vpc": "aws_vpc",
"resource_aws_default_vpc": "aws_default_vpc",
"resource_aws_security_group_rule": "aws_security_group_rule",
"resource_aws_vpc_security_group_ingress_rule": "aws_vpc_security_group_ingress_rule",
"resource_aws_vpc_security_group_egress_rule": "aws_vpc_security_group_egress_rule",
}
# Functions
is_default_security_group_of_vpc = func(reference) {
return reference matches "aws_default_security_group.(.*).id" or
reference matches "aws_vpc.(.*).default_security_group_id$" or
reference matches "aws_default_vpc.(.*).default_security_group_id$"
}
filter_security_group_rule_violations = func(sg_rule_resources) {
return collection.reject(sg_rule_resources, func(r) {
key = "config.security_group_id.references"
val = maps.get(r, key, undefined)
return !(val is defined and length(val) > 0 and is_default_security_group_of_vpc(val[0]))
})
}
# Variables
config_resources = tf.config(tfconfig.resources)
default_security_group_resources = config_resources.type(const.resource_aws_default_security_group).resources
violations = []
violations += collection.reject(default_security_group_resources, func(r) {
ingress_key = const.config + "." + const.ingress + "." + const.constant_value
egress_key = const.config + "." + const.egress + "." + const.constant_value
ingress_key_val = maps.get(r, ingress_key, undefined)
egress_key_val = maps.get(r, egress_key, undefined)
return !((ingress_key_val is defined and length(ingress_key_val) > 0) or
(egress_key_val is defined and length(egress_key_val) > 0))
})
aws_security_group_rule_resources = config_resources.type(const.resource_aws_security_group_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_rule_resources)
aws_security_group_ingress_rule_resources = config_resources.type(const.resource_aws_vpc_security_group_ingress_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_ingress_rule_resources)
aws_security_group_egress_rule_resources = config_resources.type(const.resource_aws_vpc_security_group_egress_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_egress_rule_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,17 @@
# EFS Access Point Should Enforce User Identity
## Source Sentinel Policy
`efs-access-point-should-enforce-user-identity.sentinel`
## Conversion Quality
`Perfect`
## Why it converts well
This is a simple presence check on a single planned resource type. The Sentinel policy rejects `aws_efs_access_point` resources that do not define `posix_user`, and tfpolicy can express that directly with one `resource_policy` and one `enforce` block.
## Key translation notes
- `maps.get(res.values, "posix_user", {}) is not empty` becomes `core::try(attrs.posix_user, null) != null`
- No cross-resource reasoning or reference metadata is required
## Limitations encountered
No significant tfpolicy limitation blocks this conversion.

View File

@@ -0,0 +1,9 @@
# Converted from HashiCorp PCI DSS Sentinel example: efs-access-point-should-enforce-user-identity.sentinel
# Conversion quality: Perfect
resource_policy "aws_efs_access_point" "efs_access_point_should_enforce_user_identity" {
enforce {
condition = core::try(attrs.posix_user, null) != null
error_message = "EFS access points must define posix_user"
}
}

View File

@@ -0,0 +1,50 @@
# This policy requires resources of type `aws_efs_access_point` have attribute `posix_user` should be defined.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "efs-access-point-should-enforce-user-identity",
"message": "Attribute 'posix_user' should be defined for 'aws_efs_access_point' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-4 for more details.",
"resource_aws_efs_access_point": "aws_efs_access_point",
"posix_user": "posix_user",
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_efs_access_point).resources
violations = collection.reject(resources, func(res) {
return maps.get(res.values, const.posix_user, {}) is not empty
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,17 @@
# ElastiCache Redis Replication Group Encryption at Transit Enabled
## Source Sentinel Policy
`elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel`
## Conversion Quality
`Perfect`
## Why it converts well
This is a direct boolean check on a single planned resource type. The Sentinel logic checks whether `transit_encryption_enabled` is true on `aws_elasticache_replication_group`, and tfpolicy can express the same rule directly.
## Key translation notes
- `maps.get(res, "values.transit_encryption_enabled", ...)` becomes `core::try(attrs.transit_encryption_enabled, false)`
- No resource graph traversal, config metadata, or cross-resource matching is required
## Limitations encountered
No significant tfpolicy limitation blocks this conversion.

View File

@@ -0,0 +1,9 @@
# Converted from HashiCorp PCI DSS Sentinel example: elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel
# Conversion quality: Perfect
resource_policy "aws_elasticache_replication_group" "elasticache_redis_replication_group_encryption_at_transit_enabled" {
enforce {
condition = core::try(attrs.transit_encryption_enabled, false) == true
error_message = "ElastiCache replication groups must enable transit_encryption_enabled"
}
}

View File

@@ -0,0 +1,52 @@
# This policy requires that the `transit_encryption_enabled` attribute of the `aws_elasticache_replication_group` resource is true.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticache-redis-replication-group-encryption-at-rest-enabled",
"resource_aws_elasticache_replication_group": "aws_elasticache_replication_group",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
key = "values.transit_encryption_enabled"
return maps.has(res, key) and maps.get(res, key) is true
})
}
# Variables
elasticache_replication_groups = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticache_replication_group).resources
violations = get_violations(elasticache_replication_groups)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": "Attribute 'transit_encryption_enabled' must be true for 'aws_elasticache_replication_group' resources.Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/elasticache-controls.html#elasticache-5 for more details.",
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# Elasticsearch Encrypted at Rest
## Source Sentinel Policy
`elasticsearch-encrypted-at-rest.sentinel`
## Conversion Quality
`Good`
## Why this is Good
The original intent maps cleanly to tfpolicy, but the block shape still has to be rewritten in tfpolicy terms using `core::try()` around `encrypt_at_rest[0].enabled`.
## Key translation notes
- Nested map access becomes direct tfpolicy block access
- The conversion checks the planned end state of `encrypt_at_rest`
- The outcome is preserved even though the syntax changes substantially
## Limitations encountered
This depends on the provider exposing `encrypt_at_rest` in the expected block/list structure. As with other tfpolicy policies, raw provider schema shape matters.

View File

@@ -0,0 +1,14 @@
# Converted from HashiCorp PCI DSS Sentinel example: elasticsearch-encrypted-at-rest.sentinel
# Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "elasticsearch_encrypted_at_rest" {
locals {
encrypt_at_rest = core::try(attrs.encrypt_at_rest, [])
encryption_enabled = core::try(local.encrypt_at_rest[0].enabled, false)
}
enforce {
condition = local.encryption_enabled == true
error_message = "Elasticsearch domains must enable encrypt_at_rest"
}
}

View File

@@ -0,0 +1,54 @@
# This policy requires resources of type `aws_elasticsearch_domain` have the `encrypt_at_rest` should have 'enabled' attribute set to `true`.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticsearch-encrypted-at-rest",
"message": "Attribute 'enabled' must be set to true for the attribute 'encrypt_at_rest' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-1 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
encrypt_at_rest_values = maps.get(res, "values.encrypt_at_rest", [])
return encrypt_at_rest_values is not empty and encrypt_at_rest_values[0].enabled is true
})
}
# Variables
elasticsearch_resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# Elasticsearch HTTPS Required
## Source Sentinel Policy
`elasticsearch-https-required.sentinel`
## Conversion Quality
`Good`
## Why it is not labeled Perfect
The enforcement intent is preserved, but the structure changes more noticeably than in a simple attribute check. The Sentinel version uses helper functions plus nested map lookups. The tfpolicy version rewrites that logic into direct block access with `core::try()` and separate `enforce` blocks.
## Key translation notes
- Nested `maps.get()` calls become `core::try(local.endpoint_options[0]....)`
- One compound Sentinel predicate becomes multiple focused `enforce` blocks
- The end-state requirement is preserved clearly in tfpolicy
## Limitations encountered
This conversion depends on provider schema shape for `domain_endpoint_options`. As with other tfpolicy policies, block/list/set handling must match the exposed schema exactly.

View File

@@ -0,0 +1,26 @@
# Converted from HashiCorp PCI DSS Sentinel example: elasticsearch-https-required.sentinel
# Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "https_required" {
locals {
endpoint_options = core::try(attrs.domain_endpoint_options, [])
endpoint_options_present = core::length(local.endpoint_options) > 0
enforce_https = core::try(local.endpoint_options[0].enforce_https, false)
tls_security_policy = core::try(local.endpoint_options[0].tls_security_policy, "")
}
enforce {
condition = local.endpoint_options_present
error_message = "Elasticsearch domains must define domain_endpoint_options"
}
enforce {
condition = local.enforce_https == true
error_message = "Elasticsearch domains must set domain_endpoint_options.enforce_https = true"
}
enforce {
condition = local.tls_security_policy == "Policy-Min-TLS-1-2-PFS-2023-10"
error_message = "Elasticsearch domains must use tls_security_policy 'Policy-Min-TLS-1-2-PFS-2023-10'"
}
}

View File

@@ -0,0 +1,68 @@
# This policy requires resources of type `aws_elasticsearch_domain` have the `tls_security_policy` set to latest policy that is 'Policy-Min-TLS-1-2-PFS-2023-10' and 'enforce_https' set to true for `domain_endpoint_options` attribute.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Params
param master_count_value default 3
# Constants
const = {
"policy_name": "elasticsearch-https-required",
"message": "Attribute 'tls_security_policy' must be set to latest policy that is 'Policy-Min-TLS-1-2-PFS-2023-10' and 'enforce_https' set to true for the attribute 'domain_endpoint_options' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-8 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
"enforce_https": "enforce_https",
"tls_security_policy": "tls_security_policy",
"allowed_tls_latest_policy": "Policy-Min-TLS-1-2-PFS-2023-10",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
domain_endpoint_options_values = maps.get(res, "values.domain_endpoint_options", [])
if domain_endpoint_options_values is empty {
return false
}
tls_security_policy_value = maps.get(domain_endpoint_options_values[0], const.tls_security_policy, null)
enforce_https_value = maps.get(domain_endpoint_options_values[0], const.enforce_https, true)
if tls_security_policy_value is null {
return false
}
return enforce_https_value is true and tls_security_policy_value == const.allowed_tls_latest_policy
})
}
# Variables
elasticsearch_resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# Elasticsearch In VPC Only
## Source Sentinel Policy
`elasticsearch-in-vpc-only.sentinel`
## Conversion Quality
`Limited`
## Why this is limited
The Sentinel policy is config-oriented and accepts either constant subnet IDs or references inside `vpc_options.subnet_ids`. tfpolicy does not expose the same config-level `constant_value` and `references` metadata, so it cannot preserve that distinction exactly.
## What the tfpolicy approximation does
The tfpolicy version checks the planned end state and requires `vpc_options[0].subnet_ids` to contain one or more values.
## Limitations encountered
- It validates the resulting planned subnet IDs, not whether they originated from constants vs references
- It assumes the provider exposes `vpc_options` and `subnet_ids` in the expected schema shape
- It is a useful enforcement approximation, but not a one-to-one tfconfig translation

View File

@@ -0,0 +1,14 @@
# Approximation of HashiCorp PCI DSS Sentinel example: elasticsearch-in-vpc-only.sentinel
# Exact conversion quality: Limited
resource_policy "aws_elasticsearch_domain" "elasticsearch_in_vpc_only" {
locals {
vpc_options = core::try(attrs.vpc_options, [])
subnet_ids = core::try(local.vpc_options[0].subnet_ids, [])
}
enforce {
condition = core::length(local.subnet_ids) > 0
error_message = "Elasticsearch domains should define one or more subnet_ids in vpc_options"
}
}

View File

@@ -0,0 +1,64 @@
# This policy requires resources of type `aws_elasticsearch_domain` have the `subnet_ids` should not be empty inside 'vpc_options'.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticsearch-in-vpc-only",
"message": "Attribute 'subnet_ids' should not be empty for the attribute 'vpc_options' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-2 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
"subnet_ids": "subnet_ids",
"constant_value": "constant_value",
"references": "references",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
vpc_options_values = maps.get(res, "config.vpc_options", [])
if vpc_options_values is empty {
return false
}
subnet_ids_values = maps.get(vpc_options_values[0], const.subnet_ids, [])
if subnet_ids_values is empty {
return false
}
return maps.get(subnet_ids_values, const.constant_value, []) is not empty or maps.get(subnet_ids_values, const.references, []) is not empty
})
}
# Variables
elasticsearch_resources = tf.config(tfconfig.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,20 @@
# EventBridge Bus Must Have Attached Policy
## Source Sentinel Policy
`eventbridge-custom-event-bus-should-have-attached-policy.sentinel`
## Conversion Quality
`Limited`
## Why this is only a partial conversion
The Sentinel version can compare planned event bus resources against planned policy resources cleanly inside its own collection-processing model. tfpolicy can approximate that by using `core::getresources()` and matching on `event_bus_name`, but this is not a full graph-aware translation.
## Key translation notes
- Related resources are discovered with `core::getresources("aws_cloudwatch_event_bus_policy", {})`
- Matching is done by explicit value (`event_bus_name`) rather than graph/reference semantics
- A top-level lookup map keeps the tfpolicy example readable and performant
## Limitations encountered
- This approach relies on resolved attribute values, not reference metadata
- New resources with unresolved references may not match reliably on initial creation
- `core::getresources()` is useful for scoped lookups but is not a full replacement for Sentinel graph traversal

View File

@@ -0,0 +1,22 @@
# Converted from HashiCorp PCI DSS Sentinel example: eventbridge-custom-event-bus-should-have-attached-policy.sentinel
# Conversion quality: Limited
locals {
all_event_bus_policies = core::getresources("aws_cloudwatch_event_bus_policy", {})
event_bus_policy_map = {
for policy in local.all_event_bus_policies :
policy.event_bus_name => true
}
}
resource_policy "aws_cloudwatch_event_bus" "require_attached_policy" {
locals {
bus_name = core::try(attrs.name, "")
has_attached_policy = core::try(local.event_bus_policy_map[local.bus_name], false)
}
enforce {
condition = local.has_attached_policy
error_message = "EventBridge buses must have a matching aws_cloudwatch_event_bus_policy resource"
}
}

View File

@@ -0,0 +1,76 @@
# This policy requires `aws_cloudwatch_event_bus` resources to be attached to a policy.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
# Constants
const = {
"policy_name": "eventbridge-custom-event-bus-should-have-attached-policy",
"message": "Policy should be attached for 'aws_cloudwatch_event_bus' resource. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/eventbridge-controls.html#eventbridge-3 for more details.",
"resource_aws_cloudwatch_event_bus_policy": "aws_cloudwatch_event_bus_policy",
"resource_aws_cloudwatch_event_bus": "aws_cloudwatch_event_bus",
"event_bus_name": "event_bus_name",
"name": "name",
}
# Functions
get_bus_name_complaint = func(resources) {
return collection.reject(resources, func(res) {
bus_name_values = maps.get(res, "values." + const.event_bus_name, {})
if bus_name_values is empty {
return true
}
return false
})
}
# Variables
plan_resources = tf.plan(tfplan.planned_values.resources)
event_bus_policy_resources = plan_resources.type(const.resource_aws_cloudwatch_event_bus_policy).resources
event_bus_resources = plan_resources.type(const.resource_aws_cloudwatch_event_bus).resources
event_bus_complaint = get_bus_name_complaint(event_bus_policy_resources)
if event_bus_complaint is not defined {
violations = []
}
event_bus_addresses = map event_bus_complaint as _, res {
maps.get(res, "values." + const.event_bus_name, {})
}
violations = filter event_bus_resources as _, res {
maps.get(res, "values." + const.name, {}) not in event_bus_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,25 @@
# S3 Block Public Access Bucket Level
## Source Sentinel Policy
`s3-block-public-access-bucket-level.sentinel`
## Conversion Quality
`Not convertible` as an exact translation
## What the approximation does
The tfpolicy approximation checks whether an `aws_s3_bucket` has a matching `aws_s3_bucket_public_access_block` resource and whether all four public access settings are enabled.
## Why exact conversion is not possible today
The Sentinel policy combines:
- `tfconfig/v2`
- `tfconfig-functions`
- plan-time variable resolution
- config reference metadata
- module-aware address reconstruction
Current tfpolicy guidance does not expose that full config-analysis surface. In particular, tfpolicy cannot safely reproduce the Sentinel behavior that inspects variable references and configuration graph relationships before values are fully materialized.
## Limitations encountered
- The approximation relies on resolved values via `core::getresources()`
- It cannot reproduce variable-reference evaluation from the Sentinel policy
- It may differ from Sentinel on first creation or heavily parameterized module usage

View File

@@ -0,0 +1,27 @@
# Approximation of HashiCorp PCI DSS Sentinel example: s3-block-public-access-bucket-level.sentinel
# Exact conversion quality: Not convertible
locals {
all_public_access_blocks = core::getresources("aws_s3_bucket_public_access_block", {})
compliant_public_access_blocks = {
for block in local.all_public_access_blocks :
core::try(block.bucket, "") => (
core::try(block.ignore_public_acls, false) == true &&
core::try(block.restrict_public_buckets, false) == true &&
core::try(block.block_public_acls, false) == true &&
core::try(block.block_public_policy, false) == true
)
}
}
resource_policy "aws_s3_bucket" "s3_block_public_access_bucket_level" {
locals {
bucket_name = core::try(attrs.bucket, "")
block_is_compliant = core::try(local.compliant_public_access_blocks[local.bucket_name], false)
}
enforce {
condition = local.block_is_compliant
error_message = "S3 buckets should have a matching aws_s3_bucket_public_access_block with all four public access settings enabled"
}
}

View File

@@ -0,0 +1,103 @@
# This policy verifies if the attributes of the 'aws_s3_bucket_public_access_block'
# resource (if present) block public access of an S3 general purpose bucket.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as plan
import "tfplan-functions" as tfplan
import "tfconfig-functions" as tfconfig
import "tfconfig/v2" as config
import "tfresources" as tf
import "collection/maps" as maps
import "report" as report
import "strings"
# Constants
const = {
"policy_name": "s3-block-public-access-bucket-level",
"module_address": "module_address",
"address": "address",
"message": "Bucket level Amazon S3 block public access settings are not compliant. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/s3-controls.html#s3-8 for more details.",
"resource_aws_s3_bucket": "aws_s3_bucket",
"module_prefix": "module.",
"resource_aws_s3_bucket_public_access_block": "aws_s3_bucket_public_access_block",
"public_access_block_settings": ["ignore_public_acls", "restrict_public_buckets", "block_public_acls", "block_public_policy"],
}
# Functions
is_public_access_setting_enabled = func(config, setting) {
const_val = maps.get(maps.get(config, setting, {}), "constant_value")
if const_val is defined {
return const_val is true
}
references = maps.get(maps.get(config, setting, {}), "references")
if references is defined and tfconfig.is_variable_reference(references[0]) {
return tfplan.get_variable_value(tfconfig.parse_variable_name_from_reference(references[0])) is true
}
return false
}
is_block_public_access_settings_compliant = func(config) {
return all const.public_access_block_settings as _, setting {
is_public_access_setting_enabled(config, setting)
}
}
# Prefixes the referenced s3 bucket's address with
# the module address. This is done because resource
# addresses comprise of module addresses
sanitize_referenced_s3_bucket_address = func(res) {
module_addr = res[const.module_address]
if res.config.bucket.constant_value is defined {
return ""
}
bucket_reference = res.config.bucket.references[1]
# Check for root module
if not strings.has_prefix(res[const.address], const.module_prefix) {
return bucket_reference
}
return module_addr + "." + bucket_reference
}
# Variables
config_resources = tf.config(config.resources)
compliant_public_access_block_resources = filter config_resources.type(const.resource_aws_s3_bucket_public_access_block).resources as _, res {
is_block_public_access_settings_compliant(res.config)
}
s3_bucket_addresses = map compliant_public_access_block_resources as _, res {
sanitize_referenced_s3_bucket_address(res)
}
violations = filter config_resources.type(const.resource_aws_s3_bucket).resources as _, res {
res.address not in s3_bucket_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# S3 Bucket Should Have Object Lock Enabled
## Source Sentinel Policy
`s3-bucket-should-have-object-lock-enabled.sentinel`
## Conversion Quality
`Limited`
## Why this is limited
The Sentinel policy uses `tfconfig/v2` plus reference metadata to trace `aws_s3_bucket_object_lock_configuration` resources back to their `aws_s3_bucket` resources, including module-aware address reconstruction. tfpolicy does not expose equivalent config graph metadata.
## What the tfpolicy approximation does
The tfpolicy version uses `core::getresources()` to find `aws_s3_bucket_object_lock_configuration` resources, then matches them to buckets by the resolved `bucket` value and checks the retention mode.
## Limitations encountered
- Matching depends on resolved values, not reference metadata
- Initial creation with unresolved bucket references may not match reliably
- The approximation checks the end-state relationship but cannot reproduce the Sentinel config-graph logic exactly

View File

@@ -0,0 +1,23 @@
# Approximation of HashiCorp PCI DSS Sentinel example: s3-bucket-should-have-object-lock-enabled.sentinel
# Exact conversion quality: Limited
locals {
all_object_lock_configs = core::getresources("aws_s3_bucket_object_lock_configuration", {})
object_lock_bucket_map = {
for config in local.all_object_lock_configs :
core::try(config.bucket, "") => core::try(config.rule[0].default_retention[0].mode, "")
}
}
resource_policy "aws_s3_bucket" "s3_bucket_should_have_object_lock_enabled" {
locals {
bucket_name = core::try(attrs.bucket, "")
retention_mode = core::try(local.object_lock_bucket_map[local.bucket_name], "")
object_lock_enabled = core::contains(["GOVERNANCE", "COMPLIANCE"], local.retention_mode)
}
enforce {
condition = local.object_lock_enabled
error_message = "S3 buckets should have object lock enabled with default retention mode GOVERNANCE or COMPLIANCE"
}
}

View File

@@ -0,0 +1,100 @@
# S3 Buckets should have object lock enabled
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
import "types"
# Params
param valid_mode default ["GOVERNANCE", "COMPLIANCE"]
# Constants
const = {
"policy_name": "s3-bucket-should-have-object-lock-enabled",
"message": "S3 Buckets should have object lock enabled. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/s3-controls.html#s3-15 for more details.",
"resource_aws_s3_bucket": "aws_s3_bucket",
"resource_aws_s3_bucket_object_lock_configuration": "aws_s3_bucket_object_lock_configuration",
"address": "address",
"module_address": "module_address",
"module_prefix": "module.",
"rule": "rule",
"default_retention": "default_retention",
"mode": "mode",
}
# Functions
# Prefixes the referenced S3 Bucket's address with
# the module address. This is done because resource
# addresses comprise of module addresses
sanitize_compliant_s3_bucket_address = func(res) {
module_addr = res[const.module_address]
if res.config.bucket.constant_value is defined {
return ""
}
rule_block = maps.get(res.config, const.rule, [])
if rule_block is empty {
return ""
}
default_retention = rule_block[0].default_retention[0]
if default_retention is empty {
return ""
}
mode = maps.get(default_retention, const.mode, "").constant_value
if mode is empty or mode not in valid_mode {
return ""
}
s3_bucket_reference = res.config.bucket.references[1]
# Check for root module
if not strings.has_prefix(res[const.address], const.module_prefix) {
return s3_bucket_reference
}
return module_addr + "." + s3_bucket_reference
}
# Variables
config_resources = tf.config(tfconfig.resources)
bucket_resources = config_resources.type(const.resource_aws_s3_bucket).resources
bucket_object_lock_resources = config_resources.type(const.resource_aws_s3_bucket_object_lock_configuration).resources
# Get S3 Bucket addresses that have object lock enabled
s3_bucket_addresses_with_object_lock = map bucket_object_lock_resources as _, res {
sanitize_compliant_s3_bucket_address(res)
}
# Find violations: S3 Buckets that have policy violations
violations = filter bucket_resources as _, res {
res.address not in s3_bucket_addresses_with_object_lock
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
print(report.generate_policy_report(summary))
main = rule {
violations is empty
}

View File

@@ -0,0 +1,18 @@
# Secrets Manager Auto Rotation Enabled Check
## Source Sentinel Policy
`secretsmanager-auto-rotation-enabled-check.sentinel`
## Conversion Quality
`Limited`
## Why this is limited
The Sentinel policy uses `tfconfig/v2` reference metadata to determine whether each `aws_secretsmanager_secret` is connected to an `aws_secretsmanager_secret_rotation` resource through `config.secret_id`. Current tfpolicy guidance does not expose equivalent config-level reference metadata.
## What the tfpolicy approximation does
The tfpolicy version uses `core::getresources()` to collect `aws_secretsmanager_secret_rotation` resources and matches them to secrets by planned `secret_id` / `id` values.
## Limitations encountered
- This is value matching, not true Terraform graph reasoning
- It may fail or behave differently when secret identifiers are not resolved yet during creation
- It does not preserve Sentinel's module-aware reference reconstruction exactly

View File

@@ -0,0 +1,22 @@
# Approximation of HashiCorp PCI DSS Sentinel example: secretsmanager-auto-rotation-enabled-check.sentinel
# Exact conversion quality: Limited
locals {
all_secret_rotations = core::getresources("aws_secretsmanager_secret_rotation", {})
rotation_secret_ids = {
for rotation in local.all_secret_rotations :
core::try(rotation.secret_id, "") => true
}
}
resource_policy "aws_secretsmanager_secret" "secretsmanager_auto_rotation_enabled_check" {
locals {
secret_id = core::try(attrs.id, "")
has_rotation = core::try(local.rotation_secret_ids[local.secret_id], false)
}
enforce {
condition = local.has_rotation
error_message = "Secrets Manager secrets should have a matching aws_secretsmanager_secret_rotation resource"
}
}

View File

@@ -0,0 +1,73 @@
# This policy requires resources of type `aws_secretsmanager_secret` should be configured for automatic rotation.
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
# Constants
const = {
"policy_name": "secretsmanager-auto-rotation-enabled-check",
"message": "Secrets Manager secrets should be configured for automatic rotation. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/secretsmanager-controls.html#secretsmanager-1 for more details.",
"resource_aws_secretsmanager_secret": "aws_secretsmanager_secret",
"resource_aws_secretsmanager_secret_rotation": "aws_secretsmanager_secret_rotation",
"kms_master_key_id": "kms_master_key_id",
"sqs_managed_sse_enabled": "sqs_managed_sse_enabled",
"module_prefix": "module.",
}
# Functions
get_referenced_resource_address = func(res, attr) {
references_list = maps.get(res, attr, [])
if references_list.references is empty or references_list.references is not defined {
return ""
}
referenced_address = references_list.references[1]
if strings.has_prefix(res.address, const.module_prefix) {
referenced_address = res.module_address + "." + referenced_address
}
return referenced_address
}
# Variables
secret_resources = tf.config(tfconfig.resources).type(const.resource_aws_secretsmanager_secret).resources
secret_rotation_complaint_resources = tf.config(tfconfig.resources).type(const.resource_aws_secretsmanager_secret_rotation).resources
secret_addresses = map secret_rotation_complaint_resources as _, res {
get_referenced_resource_address(res, "config.secret_id")
}
violations = filter secret_resources as _, res {
res.address not in secret_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

View File

@@ -0,0 +1,17 @@
# Step Functions State Machine Logging Enabled
## Source Sentinel Policy
`step-functions-state-machine-logging-enabled.sentinel`
## Conversion Quality
`Good`
## Why this is Good
This policy is still a single-resource planned-value check, but it relies on a nested block (`logging_configuration`) and an allowlist of valid levels. tfpolicy can express that clearly with `core::try()` and a small local allowlist.
## Key translation notes
- Nested map access becomes direct block access through `attrs.logging_configuration[0].level`
- The allowed log levels carry over directly into the tfpolicy version
## Limitations encountered
This relies on the provider exposing `logging_configuration` in the expected block/list shape. Otherwise, the enforcement intent maps cleanly.

View File

@@ -0,0 +1,15 @@
# Converted from HashiCorp PCI DSS Sentinel example: step-functions-state-machine-logging-enabled.sentinel
# Conversion quality: Good
resource_policy "aws_sfn_state_machine" "step_functions_state_machine_logging_enabled" {
locals {
logging_configuration = core::try(attrs.logging_configuration, [])
log_level = core::try(local.logging_configuration[0].level, "")
allowed_levels = ["ALL", "ERROR", "FATAL"]
}
enforce {
condition = core::contains(local.allowed_levels, local.log_level)
error_message = "Step Functions state machines must set logging_configuration.level to ALL, ERROR, or FATAL"
}
}

View File

@@ -0,0 +1,56 @@
# This policy requires AWS Step Functions state machines to have logging configuration enabled with level set to "ALL", "ERROR", or "FATAL".
# Copyright IBM Corp. 2025
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "sfn-logging-enabled",
"message": "AWS Step Functions state machines must have logging enabled with level set to 'ALL', 'ERROR', or 'FATAL'. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/stepfunctions-controls.html#stepfunctions-1 for more details.",
"resource_aws_sfn": "aws_sfn_state_machine",
"logging_config": "logging_configuration",
"required_log_levels": ["ALL", "ERROR", "FATAL"],
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_sfn).resources
violations = collection.reject(resources, func(res) {
logging_config = maps.get(res, "values." + const.logging_config, null)
if logging_config is null {
return false
}
log_level = maps.get(logging_config[0], "level", null)
if log_level is null {
return false
}
return log_level in const.required_log_levels
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,372 @@
---
name: terraform-search-import
description: Discover existing cloud resources using Terraform Search queries and bulk import them into Terraform management. Use when bringing unmanaged infrastructure under Terraform control, auditing cloud resources, or migrating to IaC.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.1.0"
compatibility: Requires Terraform >= 1.14 and providers with list resource support (always use latest provider version)
---
# Terraform Search and Bulk Import
Discover existing cloud resources using declarative queries and generate configuration for bulk import into Terraform state.
**References:**
- [Terraform Search - list block](https://developer.hashicorp.com/terraform/language/block/tfquery/list)
- [Bulk Import](https://developer.hashicorp.com/terraform/language/import/bulk)
## When to Use
- Bringing unmanaged resources under Terraform control
- Auditing existing cloud infrastructure
- Migrating from manual provisioning to IaC
- Discovering resources across multiple regions/accounts
## IMPORTANT: Check Provider Support First
**BEFORE starting, you MUST verify the target resource type is supported:**
```bash
# Check what list resources are available
./scripts/list_resources.sh aws # Specific provider
./scripts/list_resources.sh # All configured providers
```
## Decision Tree
1. **Identify target resource type** (e.g., aws_s3_bucket, aws_instance)
2. **Check if supported**: Run `./scripts/list_resources.sh <provider>`
3. **Choose workflow**:
- ** If supported**: Check for terraform version available.
- ** If terraform version is above 1.14.0** Use Terraform Search workflow (below)
- ** If not supported or terraform version is below 1.14.0 **: Use Manual Discovery workflow (see [references/MANUAL-IMPORT.md](references/MANUAL-IMPORT.md))
**Note**: The list of supported resources is rapidly expanding. Always verify current support before using manual import.
## Prerequisites
Before writing queries, verify the provider supports list resources for your target resource type.
### Discover Available List Resources
Run the helper script to extract supported list resources from your provider:
```bash
# From a directory with provider configuration (runs terraform init if needed)
./scripts/list_resources.sh aws # Specific provider
./scripts/list_resources.sh # All configured providers
```
Or manually query the provider schema:
```bash
terraform providers schema -json | jq '.provider_schemas | to_entries | map({key: (.key | split("/")[-1]), value: (.value.list_resource_schemas // {} | keys)})'
```
Terraform Search requires an initialized working directory. Ensure you have a configuration with the required provider before running queries:
```hcl
# terraform.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
```
Run `terraform init` to download the provider, then proceed with queries.
## Terraform Search Workflow (Supported Resources Only)
1. Create `.tfquery.hcl` files with `list` blocks defining search queries
2. Run `terraform query` to discover matching resources
3. Generate configuration with `-generate-config-out=<file>`
4. Review and refine generated `resource` and `import` blocks
5. Run `terraform plan` and `terraform apply` to import
## Query File Structure
Query files use `.tfquery.hcl` extension and support:
- `provider` blocks for authentication
- `list` blocks for resource discovery
- `variable` and `locals` blocks for parameterization
```hcl
# discovery.tfquery.hcl
provider "aws" {
region = "us-west-2"
}
list "aws_instance" "all" {
provider = aws
}
```
## List Block Syntax
```hcl
list "<list_type>" "<symbolic_name>" {
provider = <provider_reference> # Required
# Optional: filter configuration (provider-specific)
# The `config` block schema is provider-specific. Discover available options using `terraform providers schema -json | jq '.provider_schemas."registry.terraform.io/hashicorp/<provider>".list_resource_schemas."<resource_type>"'`
config {
filter {
name = "<filter_name>"
values = ["<value1>", "<value2>"]
}
region = "<region>" # AWS-specific
}
# Optional: limit results
limit = 100
}
```
## Supported List Resources
Provider support for list resources varies by version. **Always check what's available for your specific provider version using the discovery script.**
## Query Examples
### Basic Discovery
```hcl
# Find all EC2 instances in configured region
list "aws_instance" "all" {
provider = aws
}
```
### Filtered Discovery
```hcl
# Find instances by tag
list "aws_instance" "production" {
provider = aws
config {
filter {
name = "tag:Environment"
values = ["production"]
}
}
}
# Find instances by type
list "aws_instance" "large" {
provider = aws
config {
filter {
name = "instance-type"
values = ["t3.large", "t3.xlarge"]
}
}
}
```
### Multi-Region Discovery
```hcl
provider "aws" {
region = "us-west-2"
}
locals {
regions = ["us-west-2", "us-east-1", "eu-west-1"]
}
list "aws_instance" "all_regions" {
for_each = toset(local.regions)
provider = aws
config {
region = each.value
}
}
```
### Parameterized Queries
```hcl
variable "target_environment" {
type = string
default = "staging"
}
list "aws_instance" "by_env" {
provider = aws
config {
filter {
name = "tag:Environment"
values = [var.target_environment]
}
}
}
```
## Running Queries
```bash
# Execute queries and display results
terraform query
# Generate configuration file
terraform query -generate-config-out=imported.tf
# Pass variables
terraform query -var='target_environment=production'
```
## Query Output Format
```
list.aws_instance.all account_id=123456789012,id=i-0abc123,region=us-west-2 web-server
```
Columns: `<query_address> <identity_attributes> <name_tag>`
## Generated Configuration
The `-generate-config-out` flag creates:
```hcl
# __generated__ by Terraform
resource "aws_instance" "all_0" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
# ... all attributes
}
import {
to = aws_instance.all_0
provider = aws
identity = {
account_id = "123456789012"
id = "i-0abc123"
region = "us-west-2"
}
}
```
## Post-Generation Cleanup
Generated configuration includes all attributes. Clean up by:
1. Remove computed/read-only attributes
2. Replace hardcoded values with variables
3. Add proper resource naming
4. Organize into appropriate files
```hcl
# Before: generated
resource "aws_instance" "all_0" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
arn = "arn:aws:ec2:..." # Remove - computed
id = "i-0abc123" # Remove - computed
# ... many more attributes
}
# After: cleaned
resource "aws_instance" "web_server" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
tags = {
Name = "web-server"
Environment = var.environment
}
}
```
## Import by Identity
Generated imports use identity-based import (Terraform 1.12+):
```hcl
import {
to = aws_instance.web
provider = aws
identity = {
account_id = "123456789012"
id = "i-0abc123"
region = "us-west-2"
}
}
```
## Best Practices
### Query Design
- Start broad, then add filters to narrow results
- Use `limit` to prevent overwhelming output
- Test queries before generating configuration
### Configuration Management
- Review all generated code before applying
- Remove unnecessary default values
- Use consistent naming conventions
- Add proper variable abstraction
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "No list resources found" | Check provider version supports list resources |
| Query returns empty | Verify region and filter values |
| Generated config has errors | Remove computed attributes, fix deprecated arguments |
| Import fails | Ensure resource not already in state |
## Complete Example
```hcl
# main.tf - Initialize provider
terraform {
required_version = ">= 1.14"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0" # Always use latest version
}
}
}
# discovery.tfquery.hcl - Define queries
provider "aws" {
region = "us-west-2"
}
list "aws_instance" "team_instances" {
provider = aws
config {
filter {
name = "tag:Owner"
values = ["platform"]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
limit = 50
}
```
```bash
# Execute workflow
terraform init
terraform query
terraform query -generate-config-out=generated.tf
# Review and clean generated.tf
terraform plan
terraform apply
```

View File

@@ -0,0 +1,113 @@
# Manual Terraform Import Reference
Use this workflow when your target resource type isn't supported by Terraform Search.
## 1. Discover Resources Using Provider CLI
AWS CLI examples:
```bash
# RDS instances (not yet supported by Terraform Search)
aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'
# DynamoDB tables (not yet supported by Terraform Search)
aws dynamodb list-tables --query 'TableNames[]'
# API Gateway REST APIs (not yet supported by Terraform Search)
aws apigateway get-rest-apis --query 'items[].id'
# SNS topics (not yet supported by Terraform Search)
aws sns list-topics --query 'Topics[].TopicArn'
```
## 2. Create Resource Blocks Manually
```hcl
# Example for RDS instance
resource "aws_db_instance" "existing_db" {
identifier = "my-existing-db"
# Add other required attributes
}
# Example for DynamoDB table
resource "aws_dynamodb_table" "existing_table" {
name = "my-existing-table"
# Add other required attributes
}
# Example for SNS topic
resource "aws_sns_topic" "existing_topic" {
name = "my-existing-topic"
}
```
## 3. Create Import Blocks (Config-Driven Import)
```hcl
# Example for RDS instance
resource "aws_db_instance" "existing_db" {
identifier = "my-existing-db"
# Add other required attributes
}
import {
to = aws_db_instance.existing_db
id = "my-existing-db"
}
# Example for DynamoDB table
resource "aws_dynamodb_table" "existing_table" {
name = "my-existing-table"
# Add other required attributes
}
import {
to = aws_dynamodb_table.existing_table
id = "my-existing-table"
}
```
## 4. Run Import Plan
```bash
# Plan the import to see what will happen
terraform plan
# Apply to import the resources
terraform apply
```
## Bulk Import Script Example
For multiple resources of the same type:
```bash
#!/bin/bash
# bulk-import-dynamodb.sh
# Get all table names
tables=$(aws dynamodb list-tables --query 'TableNames[]' --output text)
# Generate import configuration
cat > dynamodb-imports.tf << 'EOF'
# DynamoDB Table Resources and Imports
EOF
for table in $tables; do
# Create resource and import blocks
cat >> dynamodb-imports.tf << EOF
resource "aws_dynamodb_table" "table_${table//[-.]/_}" {
name = "$table"
}
import {
to = aws_dynamodb_table.table_${table//[-.]/_}
id = "$table"
}
EOF
done
echo "Generated dynamodb-imports.tf with import blocks"
echo "Run 'terraform plan' to review, then 'terraform apply' to import"
```

View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Extract list resources supported by Terraform providers
# Usage: ./list_resources.sh [provider_name]
# Requires: terraform, jq
# Note: Run from an initialized Terraform directory (terraform init)
set -e
PROVIDER=$1
# Ensure terraform is initialized
if [ ! -d ".terraform" ]; then
echo "Initializing Terraform..." >&2
terraform init -upgrade > /dev/null 2>&1
fi
# Get provider schema and extract list_resource_schemas
if [ -n "$PROVIDER" ]; then
# Specific provider
provider_key=$(terraform providers schema -json 2>/dev/null | jq -r '.provider_schemas | keys[]' | grep "/${PROVIDER}$" || true)
if [ -n "$provider_key" ]; then
terraform providers schema -json 2>/dev/null | jq -r \
"{\"$PROVIDER\": (.provider_schemas.\"${provider_key}\" | .list_resource_schemas // {} | keys | sort)}"
else
echo "{\"$PROVIDER\": []}"
fi
else
# All providers
terraform providers schema -json 2>/dev/null | jq -r '
.provider_schemas
| to_entries
| map({key: (.key | split("/")[-1]), value: (.value.list_resource_schemas // {} | keys | sort)})
| from_entries
'
fi

View File

@@ -0,0 +1,480 @@
---
name: terraform-stacks
description: Comprehensive guide for working with HashiCorp Terraform Stacks. Use when creating, modifying, or validating Terraform Stack configurations (.tfcomponent.hcl, .tfdeploy.hcl files), working with stack components and deployments from local modules, public registry, or private registry sources, managing multi-region or multi-environment infrastructure, or troubleshooting Terraform Stacks syntax and structure.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Terraform Stacks
Terraform Stacks simplify infrastructure provisioning and management at scale by providing a configuration layer above traditional Terraform modules. Stacks enable declarative orchestration of multiple components across environments, regions, and cloud accounts.
## Core Concepts
**Stack**: A complete unit of infrastructure composed of components and deployments that can be managed together.
**Component**: An abstraction around a Terraform module that defines infrastructure pieces. Each component specifies a source module, inputs, and providers.
**Deployment**: An instance of all components in a stack with specific input values. Use deployments for different environments (dev/staging/prod), regions, or cloud accounts.
**Stack Language**: A separate HCL-based language (not regular Terraform HCL) with distinct blocks and file extensions.
## File Structure
Terraform Stacks use specific file extensions:
- **Component configuration**: `.tfcomponent.hcl`
- **Deployment configuration**: `.tfdeploy.hcl`
- **Provider lock file**: `.terraform.lock.hcl` (generated by CLI)
All configuration files must be at the root level of the Stack repository. HCP Terraform processes all files in dependency order.
### Recommended File Organization
```
my-stack/
├── .terraform-version # The required Terraform version for this Stack
├── variables.tfcomponent.hcl # Variable declarations
├── providers.tfcomponent.hcl # Provider configurations
├── components.tfcomponent.hcl # Component definitions
├── outputs.tfcomponent.hcl # Stack outputs
├── deployments.tfdeploy.hcl # Deployment definitions
├── .terraform.lock.hcl # Provider lock file (generated)
└── modules/ # Local modules (optional - only if using local modules)
├── s3/
└── compute/
```
**Note**: The `modules/` directory is only required when using local module sources. Components can reference modules from:
- Local file paths: `./modules/vpc`
- Public registry: `terraform-aws-modules/vpc/aws`
- Private registry: `app.terraform.io/<org-name>/vpc/aws`
- Git: `git::https://github.com/org/repo.git//path?ref=v1.0.0`
HCP Terraform processes all `.tfcomponent.hcl` and `.tfdeploy.hcl` files in dependency order.
## Required Terraform version (.terraform-version)
Use Terraform v1.13.x or later to access the Stacks CLI plugin and to run
terraform stacks CLI commands. Begin by adding a .terraform-version file to
your Stack's root directory to specify the Terraform version required for your
Stack. For example, the following file specifies Terraform v1.14.5:
```
1.14.5
```
## Component Configuration (.tfcomponent.hcl)
### Variable Block
Declare input variables for the Stack configuration. Variables must define a `type` field and do not support the `validation` argument.
```hcl
variable "aws_region" {
type = string
description = "AWS region for deployments"
default = "us-west-1"
}
variable "identity_token" {
type = string
description = "OIDC identity token"
ephemeral = true # Does not persist to state file
}
variable "instance_count" {
type = number
nullable = false
}
```
**Important**: Use `ephemeral = true` for credentials and tokens (identity tokens, API keys, passwords) to prevent them from persisting in state files. Use `stable` for longer-lived values like license keys that need to persist across runs.
### Required Providers Block
```hcl
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5.0"
}
}
```
### Provider Block
Provider blocks differ from traditional Terraform:
1. Support `for_each` meta-argument
2. Define aliases in the block header (not as an argument)
3. Accept configuration through a `config` block
**Single Provider Configuration:**
```hcl
provider "aws" "this" {
config {
region = var.aws_region
assume_role_with_web_identity {
role_arn = var.role_arn
web_identity_token = var.identity_token
}
}
}
```
**Multiple Provider Configurations with for_each:**
```hcl
provider "aws" "configurations" {
for_each = var.regions
config {
region = each.value
assume_role_with_web_identity {
role_arn = var.role_arn
web_identity_token = var.identity_token
}
}
}
```
**Authentication Best Practice**: Use **workload identity** (OIDC) as the preferred authentication method for Stacks. This approach:
- Avoids long-lived static credentials
- Provides temporary, scoped credentials per deployment run
- Integrates with cloud provider IAM (AWS IAM Roles, Azure Managed Identities, GCP Service Accounts)
- Eliminates need for platform-managed environment variables
Configure workload identity using `identity_token` blocks and `assume_role_with_web_identity` in provider configuration. For detailed setup instructions for AWS, Azure, and GCP, see: https://developer.hashicorp.com/terraform/cloud-docs/dynamic-provider-credentials
### Component Block
Each Stack requires at least one component block. Add a component for each module to include in the Stack. Components reference modules from local paths, registries, or Git.
```hcl
component "vpc" {
source = "app.terraform.io/my-org/vpc/aws" # Local, registry, or Git URL
version = "2.1.0" # For registry modules
inputs = {
cidr_block = var.vpc_cidr
name_prefix = var.name_prefix
}
providers = {
aws = provider.aws.this
}
}
```
See `references/component-blocks.md` for examples of dependencies, for_each, public registry modules, Git sources, and more.
**Key Points:**
- Reference outputs: `component.<name>.<output>` or `component.<name>[key].<output>` for for_each
- Dependencies inferred automatically from component references
- Aggregate with for expressions: `[for x in component.s3 : x.bucket_name]`
- For components with `for_each`, reference specific instances: `component.<name>[each.value].<output>`
- Provider references are normal values: `provider.<type>.<alias>` or `provider.<type>.<alias>[each.value]`
### Output Block
Outputs require a `type` argument and do not support `preconditions`:
```hcl
output "vpc_id" {
type = string
description = "VPC ID"
value = component.vpc.vpc_id
}
output "endpoint_urls" {
type = map(string)
value = {
for region, comp in component.api : region => comp.endpoint_url
}
sensitive = false
}
```
### Locals Block
Locals blocks work the same in both `.tfcomponent.hcl` and `.tfdeploy.hcl` files:
```hcl
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform Stacks"
Project = var.project_name
}
region_config = {
for region in var.regions : region => {
name_suffix = "${var.environment}-${region}"
}
}
}
```
### Removed Block
Use to safely remove components from a Stack. HCP Terraform requires the component's providers to remove it.
```hcl
removed {
from = component.old_component
source = "./modules/old-module"
providers = {
aws = provider.aws.this
}
}
```
## Deployment Configuration (.tfdeploy.hcl)
### Identity Token Block
Generate JWT tokens for OIDC authentication with cloud providers:
```hcl
identity_token "aws" {
audience = ["aws.workload.identity"]
}
identity_token "azure" {
audience = ["api://AzureADTokenExchange"]
}
```
Reference tokens in deployments using `identity_token.<name>.jwt`
### Store Block
Access HCP Terraform variable sets within Stack deployments:
```hcl
store "varset" "aws_credentials" {
id = "varset-ABC123" # Alternatively use: name = "varset_name"
source = "tfc-cloud-shared"
category = "terraform" # Alternatively use: category = "env" for environment variables
}
deployment "production" {
inputs = {
aws_access_key = store.varset.aws_credentials.AWS_ACCESS_KEY_ID
}
}
```
Use to centralize credentials and share variables across Stacks. See `references/deployment-blocks.md` for details.
### Deployment Block
Define deployment instances (minimum 1, maximum 20 per Stack):
```hcl
deployment "production" {
inputs = {
aws_region = "us-west-1"
instance_count = 3
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
}
# Create multiple deployments for different environments
deployment "development" {
inputs = {
aws_region = "us-east-1"
instance_count = 1
name_suffix = "dev"
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
}
```
**To destroy a deployment**: Set `destroy = true`, upload configuration, approve destroy run, then remove the deployment block. See `references/deployment-blocks.md` for details.
### Deployment Group Block
Group deployments together for shared settings (HCP Terraform Premium tier feature). Free/standard tiers use default groups named `{deployment-name}_default`.
```hcl
deployment_group "canary" {
auto_approve_checks = [deployment_auto_approve.safe_changes]
}
deployment "dev" {
inputs = { /* ... */ }
deployment_group = deployment_group.canary
}
```
Multiple deployments can reference the same group. See `references/deployment-blocks.md` for details.
### Deployment Auto-Approve Block
Define rules to automatically approve deployment plans (HCP Terraform Premium tier feature):
```hcl
deployment_auto_approve "safe_changes" {
deployment_group = deployment_group.canary
check {
condition = context.plan.changes.remove == 0
reason = "Cannot auto-approve plans with resource deletions"
}
}
```
**Available context variables**: `context.plan.applyable`, `context.plan.changes.add/change/remove/total`, `context.success`
**Note:** `orchestrate` blocks are deprecated. Use `deployment_group` and `deployment_auto_approve` instead.
See `references/deployment-blocks.md` for all context variables and patterns.
### Publish Output and Upstream Input Blocks
Link Stacks together by publishing outputs from one Stack and consuming them in another:
```hcl
# In network Stack - publish outputs
publish_output "vpc_id_network" {
type = string
value = deployment.network.vpc_id
}
# In application Stack - consume outputs
upstream_input "network_stack" {
type = "stack"
source = "app.terraform.io/my-org/my-project/networking-stack"
}
deployment "app" {
inputs = {
vpc_id = upstream_input.network_stack.vpc_id_network
}
}
```
See `references/linked-stacks.md` for complete documentation and examples.
## Terraform Stacks CLI
**Note**: Terraform Stacks is Generally Available (GA) as of Terraform CLI v1.13+. Stacks now count toward Resources Under Management (RUM) for HCP Terraform billing.
### Initialize and Validate
```bash
terraform stacks init # Download providers, modules, generate lock file
terraform stacks providers-lock # Regenerate lock file (add platforms if needed)
terraform stacks validate # Check syntax without uploading
```
### Deployment Workflow
**Important**: No `plan` or `apply` commands. Upload configuration triggers deployment runs automatically.
```bash
# 1. Upload configuration (triggers deployment runs)
terraform stacks configuration upload
# 2. Monitor deployments
terraform stacks deployment-run list # List runs (non-interactive)
terraform stacks deployment-group watch -deployment-group=... # Stream status updates
# 3. Approve deployments (if auto-approve not configured)
terraform stacks deployment-run approve-all-plans -deployment-run-id=...
terraform stacks deployment-group approve-all-plans -deployment-group=...
terraform stacks deployment-run cancel -deployment-run-id=... # Cancel if needed
```
### Configuration Management
```bash
terraform stacks configuration list # List configuration versions
terraform stacks configuration fetch -configuration-id=... # Download configuration
terraform stacks configuration watch # Monitor upload status
```
### Other Commands
```bash
terraform stacks create # Create new Stack (interactive)
terraform stacks fmt # Format Stack files
terraform stacks list # Show all Stacks
terraform stacks version # Display version
terraform stacks deployment-group rerun -deployment-group=... # Rerun deployment
```
## Monitoring Deployments with HCP Terraform API
For programmatic monitoring in automation, CI/CD, or non-interactive environments (like AI agents), use the HCP Terraform API instead of CLI watch commands. The API provides endpoints for:
- Configuration status and validation
- Deployment group summaries
- Deployment run status
- Deployment step details (plan/apply)
- Error diagnostics with file locations and code snippets
- Stack outputs via artifacts endpoint
**Key points:**
- CLI watch commands stream indefinitely and don't work in automation
- Use artifacts endpoint to retrieve Stack outputs: `GET /api/v2/stack-deployment-steps/{step-id}/artifacts?name=apply-description`
- Diagnostics endpoint requires `stack_deployment_step_id` query parameter
- Artifacts endpoint returns HTTP 307 redirect (use `curl -L`)
For complete API workflow, authentication, polling best practices, and example scripts, see `references/api-monitoring.md`.
## Common Patterns
**Component Dependencies**: Dependencies are automatically inferred when one component references another's output (e.g., `subnet_ids = component.vpc.private_subnet_ids`).
**Multi-Region Deployment**: Use `for_each` on providers and components to deploy across multiple regions. Each region gets its own provider configuration and component instances.
**Deferred Changes**: Stacks support deferred changes to handle dependencies where values are only known after apply. This enables complex multi-component deployments where some resources depend on runtime values from other components (cluster endpoints, generated passwords, etc.).
For complete examples including multi-region deployments, component dependencies, deferred changes patterns, and linked Stacks, see `references/examples.md`.
## Best Practices
1. **Component Granularity**: Create components for logical infrastructure units that share a lifecycle
2. **Module Compatibility**:
- Modules used with Stacks cannot include provider blocks (configure providers in Stack configuration)
- **Test public registry modules** before using in production Stacks - some modules may have compatibility issues
- Consider using raw resources for critical infrastructure if module compatibility is uncertain
- Example: Some terraform-aws-modules versions have been found to have compatibility issues with Stacks (e.g., ALB and ECS modules)
3. **State Isolation**: Each deployment has its own isolated state
4. **Input Variables**: Use variables for values that differ across deployments; use locals for shared values
5. **Provider Lock Files**: Always generate and commit `.terraform.lock.hcl` to version control
6. **Naming Conventions**: Use descriptive names for components and deployments
7. **Deployment Groups**: You can organize deployments into deployment groups. Deployment groups enable auto-approval rules, logical organization, and provide a foundation for scaling. Deployment groups are an HCP Terraform Premium tier feature
8. **Testing**: Test Stack configurations in dev/staging deployments before production
## Troubleshooting
**Circular Dependencies**: Refactor to break circular references or use intermediate components.
**Deployment Destruction**: Cannot destroy from UI. Set `destroy = true` in deployment block, upload configuration, and HCP Terraform creates a destroy run.
**Empty Diagnostics**: Add required `stack_deployment_step_id` query parameter to diagnostics API requests.
**Module Compatibility**: Test public registry modules before production use. Some modules may have compatibility issues with Stacks.
## References
For detailed documentation, see:
- `references/component-blocks.md` - Complete component block reference with all arguments and syntax
- `references/deployment-blocks.md` - Complete deployment block reference with all configuration options
- `references/linked-stacks.md` - Publish outputs and upstream inputs for linking Stacks together
- `references/examples.md` - Complete working examples for multi-region and component dependencies
- `references/api-monitoring.md` - Full API workflow for programmatic monitoring and automation
- `references/troubleshooting.md` - Detailed troubleshooting guide for common issues and solutions

View File

@@ -0,0 +1,543 @@
# API Monitoring Reference
Complete guide for monitoring Terraform Stack deployments using the HCP Terraform API. Use this approach for automation, CI/CD pipelines, and non-interactive environments like AI agents.
## Table of Contents
1. [When to Use the API](#when-to-use-the-api)
2. [Authentication](#authentication)
3. [API Monitoring Workflow](#api-monitoring-workflow)
4. [Detailed Endpoint Reference](#detailed-endpoint-reference)
5. [Notes for AI Agents and Automation](#notes-for-ai-agents-and-automation)
## When to Use the API
Use the HCP Terraform API instead of CLI commands when:
- Running in non-interactive environments (CI/CD, automation scripts)
- Building tools or integrations that need programmatic access
- Monitoring multiple Stacks simultaneously
- Implementing custom retry logic or error handling
- Working in environments where streaming CLI commands don't work
**CLI commands that don't work in automation:**
- `terraform stacks deployment-run watch` - Streams output, blocks indefinitely
- `terraform stacks deployment-group watch` - Streams output, blocks indefinitely
- `terraform stacks configuration watch` - Streams output, blocks indefinitely
## Authentication
### Extract API Token from Credentials File
```bash
TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json)
```
### Alternative: Use Environment Variable
```bash
export TFC_TOKEN="your-token-here"
TOKEN=$TFC_TOKEN
```
### API Request Headers
All API requests require these headers:
```bash
-H "Authorization: Bearer $TOKEN"
-H "Content-Type: application/vnd.api+json"
```
## API Monitoring Workflow
After uploading a configuration with `terraform stacks configuration upload`, follow this sequence to monitor deployment progress:
### Step 1: Get Configuration Status
**Endpoint:** `GET /api/v2/stack-configurations/{configuration-id}`
**Purpose:** Verify configuration upload completed successfully and get the configuration details.
**Request:**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/stack-configurations/{configuration-id}" | jq '.'
```
**Response Fields:**
- `attributes.status` - Configuration processing status (pending/completed)
- `attributes.sequence-number` - Version number of this configuration
- `attributes.components-detected` - Number of components found
- `attributes.deployments-detected` - Number of deployments found
**Example Response:**
```json
{
"data": {
"id": "stc-ABC123",
"type": "stack-configurations",
"attributes": {
"status": "completed",
"sequence-number": 5,
"components-detected": 3,
"deployments-detected": 2,
"created-at": "2024-01-15T10:30:00.000Z",
"updated-at": "2024-01-15T10:30:45.000Z"
}
}
}
```
### Step 2: Get Deployment Group Summaries
**Endpoint:** `GET /api/v2/stack-configurations/{configuration-id}/stack-deployment-group-summaries`
**Purpose:** Get list of deployment groups, their IDs, and current status summary.
**Request:**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/stack-configurations/{configuration-id}/stack-deployment-group-summaries" | jq '.'
```
**Response Fields:**
- `id` - Deployment group ID (needed for next step)
- `attributes.name` - Deployment group name (e.g., `dev_default`)
- `attributes.status` - Overall status (running/succeeded/failed)
- `attributes.status-counts` - Breakdown of deployment statuses
**Example Response:**
```json
{
"data": [
{
"id": "sdg-XYZ789",
"type": "stack-deployment-group-summaries",
"attributes": {
"name": "dev_default",
"status": "running",
"status-counts": {
"pending": 0,
"running": 1,
"succeeded": 1,
"failed": 0
}
}
}
]
}
```
### Step 3: Get Deployment Runs
**Endpoint:** `GET /api/v2/stack-deployment-groups/{group-id}/stack-deployment-runs`
**Purpose:** Get list of deployment runs for a specific group with their current status.
**Request:**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/stack-deployment-groups/{group-id}/stack-deployment-runs" | jq '.'
```
**Response Fields:**
- `id` - Deployment run ID (needed for next step)
- `attributes.status` - Current status (planning/planned/applying/applied/failed)
- `attributes.created-at` - Run start time
- `attributes.updated-at` - Last update time
**Example Response:**
```json
{
"data": [
{
"id": "sdr-123ABC",
"type": "stack-deployment-runs",
"attributes": {
"status": "planning",
"created-at": "2024-01-15T10:31:00.000Z",
"updated-at": "2024-01-15T10:31:15.000Z"
}
}
]
}
```
### Step 4: Get Deployment Steps
**Endpoint:** `GET /api/v2/stack-deployment-runs/{run-id}/stack-deployment-steps`
**Purpose:** Get detailed information about individual plan and apply steps.
**Request:**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/stack-deployment-runs/{run-id}/stack-deployment-steps" | jq '.'
```
**Response Fields:**
- `id` - Step ID (needed for diagnostics and outputs)
- `attributes.operation-type` - Type of operation (plan/apply)
- `attributes.status` - Step status (running/completed/failed)
- `attributes.component-name` - Which component is being processed
**Example Response:**
```json
{
"data": [
{
"id": "sds-PlanStep123",
"type": "stack-deployment-steps",
"attributes": {
"operation-type": "plan",
"status": "completed",
"component-name": "vpc",
"created-at": "2024-01-15T10:31:05.000Z",
"completed-at": "2024-01-15T10:31:30.000Z"
}
},
{
"id": "sds-ApplyStep456",
"type": "stack-deployment-steps",
"attributes": {
"operation-type": "apply",
"status": "running",
"component-name": "vpc",
"created-at": "2024-01-15T10:32:00.000Z"
}
}
]
}
```
### Step 5: Get Error Diagnostics (When Deployment Fails)
**Endpoint:** `GET /api/v2/stack-deployment-steps/{step-id}/stack-diagnostics`
**Purpose:** Retrieve detailed error messages when a deployment step fails.
**Critical:** The `stack_deployment_step_id` query parameter is **required**. Without it, the API returns empty results.
**Request:**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/stack-diagnostics?stack_deployment_step_id={step-id}" | jq '.'
```
**Response Fields:**
- `attributes.severity` - Diagnostic level (error/warning)
- `attributes.summary` - Brief error description
- `attributes.detail` - Detailed error message
- `attributes.diags` - Array of diagnostic objects with file locations and code snippets
**Example Response (Error with Details):**
```json
{
"data": [
{
"id": "stf-ErrorExampleId",
"type": "stack-diagnostics",
"attributes": {
"severity": "error",
"summary": "Diagnostics reported",
"detail": "2 errors",
"diags": [
{
"summary": "Unsupported attribute",
"detail": "This object does not have an attribute named \"target_id\".",
"range": {
"filename": "main.tf",
"start": {
"line": 634,
"column": 33
},
"end": {
"line": 634,
"column": 43
},
"source": "registry.terraform.io/terraform-aws-modules/alb/aws@9.17.0//main.tf"
},
"snippet": {
"code": " target_id = each.value.target_id",
"context": "resource \"aws_lb_target_group_attachment\" \"this\""
}
},
{
"summary": "Invalid reference",
"detail": "A reference to a resource type must be followed by at least one attribute access.",
"range": {
"filename": "main.tf",
"start": {
"line": 142,
"column": 15
},
"end": {
"line": 142,
"column": 28
},
"source": "local-module//main.tf"
},
"snippet": {
"code": " vpc_id = aws_vpc.main",
"context": "resource \"aws_subnet\" \"private\""
}
}
],
"acknowledged": false,
"created-at": "2024-01-15T10:32:15.000Z"
}
}
]
}
```
**Parsing Diagnostics:**
Extract error information with jq:
```bash
# Get error summaries
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/stack-diagnostics?stack_deployment_step_id={step-id}" | \
jq -r '.data[].attributes.diags[]? | "\(.summary): \(.detail)"'
# Get file locations
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/stack-diagnostics?stack_deployment_step_id={step-id}" | \
jq -r '.data[].attributes.diags[]? | "\(.range.filename):\(.range.start.line)"'
```
### Step 6: Get Stack Outputs (After Successful Deployment)
**Endpoint:** `GET /api/v2/stack-deployment-steps/{final-apply-step-id}/artifacts?name=apply-description`
**Purpose:** Retrieve Stack outputs after a successful deployment completes.
**Important Notes:**
- This endpoint returns HTTP 307 redirect - use `curl -L` to follow redirects automatically
- This is currently the **only way** to retrieve Stack outputs programmatically
- This endpoint is **not documented** in public API documentation
- You need the final apply step ID from Step 4
**Request:**
```bash
curl -L -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{final-apply-step-id}/artifacts?name=apply-description"
```
**Response Structure:**
The artifact response includes an `.outputs` object where each output contains a `change.after` property with the actual output value:
```json
{
"outputs": {
"alb_url": {
"change": {
"actions": ["no-op"],
"before": "http://my-alb-123456789.us-west-2.elb.amazonaws.com",
"after": "http://my-alb-123456789.us-west-2.elb.amazonaws.com",
"after_unknown": false,
"before_sensitive": false,
"after_sensitive": false
},
"type": "string"
},
"ecr_repository_url": {
"change": {
"actions": ["no-op"],
"before": "123456789.dkr.ecr.us-west-2.amazonaws.com/my-repo",
"after": "123456789.dkr.ecr.us-west-2.amazonaws.com/my-repo",
"after_unknown": false,
"before_sensitive": false,
"after_sensitive": false
},
"type": "string"
}
}
}
```
**Extract Only Output Values:**
```bash
curl -L -s --header "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{final-apply-step-id}/artifacts?name=apply-description" | \
jq -r '.outputs | to_entries | .[] | "\(.key): \(.value.change.after)"'
```
**Example Output:**
```
alb_url: http://my-alb-123456789.us-west-2.elb.amazonaws.com
ecr_repository_url: 123456789.dkr.ecr.us-west-2.amazonaws.com/my-repo
```
## Detailed Endpoint Reference
### Available Artifact Types
The artifacts endpoint accepts these `name` parameter values:
- `plan-description` - Terraform plan output in JSON format
- `plan-debug-log` - Detailed debug logs from plan operation
- `apply-description` - Terraform apply output including outputs (JSON format)
- `apply-debug-log` - Detailed debug logs from apply operation
### Polling Best Practices
**Recommended polling intervals:**
- Configuration status: Check every 5 seconds until status is "completed"
- Deployment runs: Check every 10 seconds during active deployment
- Deployment steps: Check every 10 seconds for individual step status
**Implement exponential backoff:**
```bash
# Example polling script with backoff
RETRY_COUNT=0
MAX_RETRIES=30
BACKOFF=5
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
STATUS=$(curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-runs/{run-id}" | \
jq -r '.data.attributes.status')
if [ "$STATUS" = "applied" ] || [ "$STATUS" = "failed" ]; then
echo "Deployment finished with status: $STATUS"
break
fi
echo "Current status: $STATUS. Waiting ${BACKOFF}s..."
sleep $BACKOFF
RETRY_COUNT=$((RETRY_COUNT + 1))
done
```
## Notes for AI Agents and Automation
### CLI Command Limitations
**These CLI commands DO NOT work in automation:**
- `terraform stacks deployment-run watch` - Streams output, blocks indefinitely
- `terraform stacks deployment-group watch` - Streams output, blocks indefinitely
- `terraform stacks configuration watch` - Streams output, blocks indefinitely
**Solution:** Use API polling instead of watch commands.
### No Direct Output Command
There is currently no CLI command to retrieve Stack outputs. You must:
1. Use API to get deployment steps
2. Find the final apply step ID
3. Request the `apply-description` artifact
4. Parse JSON to extract outputs
### Handling Redirects
The artifacts endpoint returns HTTP 307 redirect to the actual artifact location. Ensure your HTTP client follows redirects:
**curl:** Use `-L` flag
**Python requests:** Set `allow_redirects=True` (default)
**Node.js fetch:** Set `redirect: 'follow'` (default)
### Error Handling
**Common API errors:**
- **401 Unauthorized:** Invalid or expired token - refresh credentials
- **404 Not Found:** Invalid ID or resource doesn't exist yet - retry with backoff
- **429 Too Many Requests:** Rate limited - implement exponential backoff
- **Empty diagnostics:** Missing required `stack_deployment_step_id` query parameter
### Complete Monitoring Script Example
```bash
#!/bin/bash
# Configuration
TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json)
CONFIG_ID="stc-ABC123"
BASE_URL="https://app.terraform.io/api/v2"
# Helper function
api_get() {
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
"$1"
}
# 1. Wait for configuration to complete
echo "Checking configuration status..."
while true; do
STATUS=$(api_get "$BASE_URL/stack-configurations/$CONFIG_ID" | jq -r '.data.attributes.status')
[ "$STATUS" = "completed" ] && break
echo "Configuration status: $STATUS. Waiting..."
sleep 5
done
# 2. Get deployment groups
echo "Getting deployment groups..."
GROUP_ID=$(api_get "$BASE_URL/stack-configurations/$CONFIG_ID/stack-deployment-group-summaries" | \
jq -r '.data[0].id')
# 3. Get deployment run
echo "Getting deployment run..."
RUN_ID=$(api_get "$BASE_URL/stack-deployment-groups/$GROUP_ID/stack-deployment-runs" | \
jq -r '.data[0].id')
# 4. Monitor deployment run
echo "Monitoring deployment run: $RUN_ID"
while true; do
STATUS=$(api_get "$BASE_URL/stack-deployment-runs/$RUN_ID" | jq -r '.data.attributes.status')
echo "Deployment status: $STATUS"
if [ "$STATUS" = "applied" ]; then
echo "Deployment succeeded!"
# 5. Get outputs from final apply step
APPLY_STEP=$(api_get "$BASE_URL/stack-deployment-runs/$RUN_ID/stack-deployment-steps" | \
jq -r '.data[] | select(.attributes["operation-type"] == "apply") | .id' | tail -1)
echo "Retrieving outputs from step: $APPLY_STEP"
curl -L -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/stack-deployment-steps/$APPLY_STEP/artifacts?name=apply-description" | \
jq -r '.outputs | to_entries | .[] | "\(.key): \(.value.change.after)"'
break
fi
if [ "$STATUS" = "failed" ]; then
echo "Deployment failed!"
# Get error diagnostics
FAILED_STEP=$(api_get "$BASE_URL/stack-deployment-runs/$RUN_ID/stack-deployment-steps" | \
jq -r '.data[] | select(.attributes.status == "failed") | .id' | head -1)
echo "Error diagnostics from step: $FAILED_STEP"
api_get "$BASE_URL/stack-deployment-steps/$FAILED_STEP/stack-diagnostics?stack_deployment_step_id=$FAILED_STEP" | \
jq -r '.data[].attributes.diags[]? | "\(.summary): \(.detail)"'
exit 1
fi
sleep 10
done
```
This script demonstrates a complete monitoring workflow from configuration upload to output retrieval with error handling.

View File

@@ -0,0 +1,476 @@
# Component Configuration Block Reference
Complete reference for all blocks available in Terraform Stack component configuration files (`.tfcomponent.hcl`).
## Table of Contents
1. [Variable Block](#variable-block)
2. [Required Providers Block](#required-providers-block)
3. [Provider Block](#provider-block)
4. [Component Block](#component-block)
5. [Output Block](#output-block)
6. [Locals Block](#locals-block)
7. [Removed Block](#removed-block)
## Variable Block
Declares input variables for Stack configuration.
### Syntax
```hcl
variable "variable_name" {
type = <type>
description = "<description>"
default = <value>
sensitive = <bool>
nullable = <bool>
ephemeral = <bool>
}
```
### Arguments
- **type** (required): Data type (string, number, bool, list, map, object, set, tuple, any)
- **description** (optional): Variable description
- **default** (optional): Default value
- **sensitive** (optional, default false): Mark as sensitive to redact from logs
- **nullable** (optional, default true): Whether null is allowed
- **ephemeral** (optional, default false): Do not persist to state file
### Differences from Traditional Terraform
- **type** is required (not optional)
- **validation** argument is not supported
### Examples
```hcl
variable "aws_region" {
type = string
description = "AWS region for infrastructure"
default = "us-west-1"
}
variable "identity_token" {
type = string
description = "OIDC identity token"
ephemeral = true
}
variable "subnet_config" {
type = object({
cidr_block = string
availability_zone = string
map_public_ip = bool
})
}
```
For complete variable examples in context, see `examples.md`.
## Required Providers Block
Declares provider dependencies.
### Syntax
```hcl
required_providers {
<provider_name> = {
source = "<source>"
version = "<version_constraint>"
}
}
```
### Arguments
- **source** (required): Provider source address (e.g., "hashicorp/aws")
- **version** (optional): Version constraint (e.g., "~> 5.0")
### Examples
```hcl
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.7.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.0"
}
}
```
## Provider Block
Configures provider instances.
### Syntax
```hcl
provider "<provider_type>" "<alias>" {
for_each = <map_or_set> # Optional
config {
<provider_arguments>
}
}
```
### Arguments
- **provider_type** (label 1, required): Provider type (e.g., "aws", "azurerm")
- **alias** (label 2, required): Unique identifier for this provider configuration
- **for_each** (optional): Create multiple provider instances from a map or set
- **config** (required): Nested block containing provider-specific configuration
### Key Differences from Traditional Terraform
1. Alias is defined in block header, not as an argument
2. Configuration goes in a nested `config` block
3. Supports `for_each` meta-argument
4. Provider configurations are treated as first-class values
### Example
```hcl
provider "aws" "main" {
config {
region = var.aws_region
assume_role_with_web_identity {
role_arn = var.role_arn
web_identity_token = var.identity_token
}
}
}
```
For complete provider examples including for_each and multi-cloud patterns, see `examples.md`.
## Component Block
Defines infrastructure components to include in the Stack.
### Syntax
```hcl
component "<component_name>" {
for_each = <map_or_set> # Optional
source = "<module_source>"
inputs = {
<input_name> = <value>
}
providers = {
<provider_local_name> = provider.<type>.<alias>[<key>]
}
}
```
### Arguments
- **component_name** (label, required): Unique identifier for this component
- **for_each** (optional): Create multiple component instances
- **source** (required): Module source (see [Source Argument](#source-argument) below)
- **version** (optional): Version constraint for registry-based sources only
- **inputs** (required): Map of input variables for the module
- **providers** (required): Map of provider configurations
### Source Argument
The `source` argument accepts the same module sources as traditional Terraform configurations.
**Local File Path:**
```hcl
source = "./modules/vpc"
source = "../shared-modules/networking"
```
**Public Terraform Registry:**
```hcl
source = "terraform-aws-modules/vpc/aws"
source = "hashicorp/consul/aws"
```
Format: `<NAMESPACE>/<NAME>/<PROVIDER>`
**Private HCP Terraform Registry:**
```hcl
source = "app.terraform.io/my-org/vpc/aws"
source = "app.terraform.io/example-corp/networking/azurerm"
```
Format: `<HOSTNAME>/<ORGANIZATION>/<MODULE_NAME>/<PROVIDER_NAME>`
- **HCP Terraform (SaaS)**: Use hostname `app.terraform.io`
- **Terraform Enterprise**: Use your instance hostname (e.g., `terraform.mycompany.com`)
- **Generic hostname**: Use `localterraform.com` for deployments spanning multiple Terraform Enterprise instances
**Git Repository:**
```hcl
source = "git::https://github.com/org/repo.git//modules/vpc?ref=v1.0.0"
source = "git::ssh://git@github.com/org/repo.git//modules/vpc?ref=main"
```
**HTTP/HTTPS Archive:**
```hcl
source = "https://example.com/modules/vpc-module.tar.gz"
```
### Version Argument
The `version` argument is supported only for registry-based sources (public and private registries). Local file paths and Git sources do not support the `version` argument.
```hcl
component "vpc" {
source = "app.terraform.io/my-org/vpc/aws"
version = "~> 2.0" # Semantic versioning constraint
inputs = {
cidr_block = var.vpc_cidr
}
providers = {
aws = provider.aws.main
}
}
```
**Note**: Modules sourced from local file paths always share the same version as their caller and cannot have independent version constraints.
### Component References
Access component outputs using: `component.<name>.<output>`
For components with `for_each`: `component.<name>[<key>].<output>`
### Examples
**Basic Component:**
```hcl
component "vpc" {
source = "app.terraform.io/my-org/vpc/aws"
version = "2.1.0"
inputs = {
cidr_block = var.vpc_cidr
name_prefix = var.name_prefix
}
providers = {
aws = provider.aws.main
}
}
```
**Component with Dependencies:**
```hcl
component "database" {
source = "./modules/rds"
inputs = {
vpc_id = component.vpc.vpc_id
subnet_ids = component.vpc.private_subnet_ids
security_group_ids = [component.security.database_sg_id]
engine_version = var.db_engine_version
}
providers = {
aws = provider.aws.main
}
}
```
For complete component examples including for_each, multi-region, public registry, and multi-provider patterns, see `examples.md`.
## Output Block
Exposes values from Stack configuration.
### Syntax
```hcl
output "<output_name>" {
type = <type>
description = "<description>"
value = <expression>
sensitive = <bool>
ephemeral = <bool>
}
```
### Arguments
- **output_name** (label, required): Unique identifier for this output
- **type** (required): Data type of the output
- **description** (optional): Output description
- **value** (required): Expression to output
- **sensitive** (optional, default false): Mark as sensitive
- **ephemeral** (optional, default false): Ephemeral value
### Differences from Traditional Terraform
- **type** is required
- **precondition** block is not supported
### Examples
```hcl
output "vpc_id" {
type = string
description = "VPC ID"
value = component.vpc.vpc_id
}
output "instance_details" {
type = object({
id = string
public_ip = string
private_ip = string
})
description = "EC2 instance details"
value = {
id = component.compute.instance_id
public_ip = component.compute.public_ip
private_ip = component.compute.private_ip
}
}
```
For complete output examples including sensitive outputs and for expressions, see `examples.md`.
## Locals Block
Defines local values for reuse within the Stack configuration.
### Syntax
```hcl
locals {
<name> = <expression>
}
```
### Example
```hcl
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform Stacks"
Project = var.project_name
}
name_prefix = "${var.project_name}-${var.environment}"
region_config = {
for region in var.regions : region => {
name_suffix = region
instance_count = var.environment == "prod" ? 3 : 1
}
}
}
```
## Removed Block
Declares components to be removed from the Stack.
### Syntax
```hcl
removed {
from = component.<component_name>
source = "<original_module_source>"
providers = {
<provider_name> = provider.<type>.<alias>
}
}
```
### Arguments
- **from** (required): Reference to the component being removed
- **source** (required): Original module source
- **providers** (required): Provider configurations needed for removal
### Important Notes
- Required for safe component removal
- Must include all providers the component used
- Do not remove providers before removing components that use them
### Examples
```hcl
removed {
from = component.old_component
source = "./modules/deprecated-module"
providers = {
aws = provider.aws.main
}
}
removed {
from = component.legacy_regional
source = "registry.terraform.io/example/legacy/aws"
providers = {
aws = provider.aws.main
random = provider.random.main
}
}
```
## Provider References in Component Blocks
### Single Provider
```hcl
providers = {
aws = provider.aws.main
}
```
### Multiple Providers
```hcl
providers = {
aws = provider.aws.main
random = provider.random.main
tls = provider.tls.main
}
```
### Provider from for_each
```hcl
providers = {
aws = provider.aws.regional[each.value]
}
```
### Aliased Providers in Module
If module requires specific provider aliases:
```hcl
providers = {
aws.source = provider.aws.us_east
aws.dest = provider.aws.eu_west
}
```

View File

@@ -0,0 +1,391 @@
# Deployment Configuration Block Reference
Complete reference for all blocks available in Terraform Stack deployment configuration files (`.tfdeploy.hcl`).
## Table of Contents
1. [Identity Token Block](#identity-token-block)
2. [Locals Block](#locals-block)
3. [Deployment Block](#deployment-block)
4. [Deployment Group Block](#deployment-group-block)
5. [Deployment Auto-Approve Block](#deployment-auto-approve-block)
**Note**: For Publish Output and Upstream Input blocks (linked Stacks), see `linked-stacks.md`.
## Identity Token Block
Generates JWT tokens for OIDC authentication with cloud providers.
### Syntax
```hcl
identity_token "<token_name>" {
audience = [<audience_strings>]
}
```
### Arguments
- **token_name** (label, required): Unique identifier for this token
- **audience** (required): List of audience strings for the JWT
### Accessing Token
Reference the JWT using: `identity_token.<n>.jwt`
### Cloud Provider Audiences
**AWS:**
```hcl
identity_token "aws" {
audience = ["aws.workload.identity"]
}
```
**Azure:**
```hcl
identity_token "azure" {
audience = ["api://AzureADTokenExchange"]
}
```
**Google Cloud:**
```hcl
identity_token "gcp" {
audience = ["//iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/<POOL_ID>/providers/<PROVIDER_ID>"]
}
```
**Setup Documentation:** For detailed instructions on configuring OIDC/workload identity for each cloud provider (including IAM roles, trust policies, and federated credentials), see: https://developer.hashicorp.com/terraform/cloud-docs/dynamic-provider-credentials
### Examples
**Single Token:**
```hcl
identity_token "aws" {
audience = ["aws.workload.identity"]
}
deployment "production" {
inputs = {
identity_token = identity_token.aws.jwt
role_arn = var.role_arn
}
}
```
For complete working examples including multi-region identity token usage, see `examples.md`.
## Locals Block
Defines local values for reuse within deployment configuration.
### Syntax
```hcl
locals {
<n> = <expression>
}
```
### Example
```hcl
locals {
aws_regions = ["us-west-1", "us-east-1", "eu-west-1"]
role_arn = "arn:aws:iam::123456789012:role/hcp-terraform-stacks"
common_inputs = {
project_name = "my-app"
environment = "production"
}
}
```
## Deployment Block
Defines deployment instances of the Stack.
### Syntax
```hcl
deployment "<deployment_name>" {
inputs = {
<input_name> = <value>
}
}
```
### Arguments
- **deployment_name** (label, required): Unique identifier for this deployment
- **inputs** (required): Map of input variable values
- **destroy** (optional, default: false): Boolean flag to destroy this deployment
### Constraints
- Minimum 1 deployment per Stack
- Maximum 20 deployments per Stack
- No meta-arguments supported (no `for_each`, `count`)
### Destroying a Deployment
To safely remove a deployment from your Stack:
1. Set `destroy = true` in the deployment block
2. Apply the plan through HCP Terraform
3. After successful destruction, remove the deployment block from your configuration
**Important**: Using the `destroy` argument ensures your configuration has the provider authentication necessary to properly destroy the deployment's resources.
**Example:**
```hcl
deployment "old_environment" {
inputs = {
aws_region = "us-west-1"
instance_count = 2
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
destroy = true # Mark for destruction
}
```
After applying this plan and the deployment is destroyed, remove the entire `deployment "old_environment"` block from your configuration.
### Examples
**Single Deployment:**
```hcl
deployment "production" {
inputs = {
aws_region = "us-west-1"
instance_count = 5
instance_type = "t3.large"
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
}
```
**Using Locals for Multiple Deployments:**
```hcl
locals {
common_inputs = {
role_arn = "arn:aws:iam::123456789012:role/terraform"
identity_token = identity_token.aws.jwt
project_name = "my-app"
}
}
deployment "dev" {
inputs = merge(local.common_inputs, {
aws_region = "us-east-1"
instance_count = 1
environment = "dev"
})
}
deployment "prod" {
inputs = merge(local.common_inputs, {
aws_region = "us-west-1"
instance_count = 5
environment = "prod"
})
}
```
For complete multi-environment and multi-region deployment examples, see `examples.md`.
## Deployment Group Block
Groups deployments together to configure shared settings and auto-approval rules (HCP Terraform Premium tier feature).
### Syntax
```hcl
deployment_group "<group_name>" {
deployments = [<deployment_references>]
}
```
### Arguments
- **group_name** (label, required): Unique identifier for this deployment group
- **deployments** (required): List of deployment references to include in this group
### Purpose
Deployment groups allow you to:
- Organize deployments logically (by environment, team, region, etc.)
- Configure shared auto-approval rules for multiple deployments
- Manage deployments more effectively at scale
- Establish consistent configuration patterns across all Stacks
### Examples
**Single Deployment Group (Best Practice):**
```hcl
deployment "production" {
inputs = {
aws_region = "us-west-1"
instance_count = 5
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
}
deployment_group "production" {
deployments = [deployment.production]
}
```
**Multiple Deployment Groups:**
```hcl
deployment_group "non_production" {
deployments = [
deployment.development,
deployment.staging
]
}
deployment_group "production" {
deployments = [
deployment.prod_us_east,
deployment.prod_us_west,
deployment.prod_eu_west
]
}
```
## Deployment Auto-Approve Block
Defines rules that automatically approve deployment plans based on specific conditions (HCP Terraform Premium feature).
### Syntax
```hcl
deployment_auto_approve "<rule_name>" {
deployment_group = deployment_group.<group_name>
check {
condition = <boolean_expression>
reason = "<failure_message>"
}
}
```
### Arguments
- **rule_name** (label, required): Unique identifier for this auto-approve rule
- **deployment_group** (required): Reference to the deployment group this rule applies to
- **check** (required, one or more): Condition that must be met for auto-approval
### Context Variables
Access plan information through `context` object:
- `context.plan.applyable` - Boolean: plan succeeded without errors
- `context.plan.changes.add` - Number: resources to add
- `context.plan.changes.change` - Number: resources to change
- `context.plan.changes.remove` - Number: resources to remove
- `context.plan.changes.import` - Number: resources to import
### Important Notes
- All checks must pass for auto-approval to occur
- If any check fails, manual approval is required
- HCP Terraform displays the failure reason from failed checks
- Auto-approve rules only apply to deployments in the specified deployment group
### Examples
**Auto-approve Successful Plans:**
```hcl
deployment_group "canary" {
deployments = [
deployment.dev,
deployment.staging
]
}
deployment_auto_approve "applyable_plans" {
deployment_group = deployment_group.canary
check {
condition = context.plan.applyable
reason = "Plan must be applyable without errors"
}
}
```
**Auto-approve Non-Destructive Changes:**
```hcl
deployment_group "production" {
deployments = [
deployment.prod_primary,
deployment.prod_secondary
]
}
deployment_auto_approve "safe_production_changes" {
deployment_group = deployment_group.production
check {
condition = context.plan.changes.remove == 0
reason = "Production deletions require manual approval"
}
check {
condition = context.plan.applyable
reason = "Plan must be successful"
}
}
```
**Graduated Rollout Pattern:**
```hcl
deployment_group "canary" {
deployments = [deployment.canary]
}
deployment_group "production" {
deployments = [
deployment.prod_us,
deployment.prod_eu,
deployment.prod_asia
]
}
# Canary auto-approves with strict checks
deployment_auto_approve "canary_strict" {
deployment_group = deployment_group.canary
check {
condition = context.plan.changes.remove == 0
reason = "Canary cannot delete resources"
}
check {
condition = context.plan.changes.change <= 5
reason = "Canary limited to 5 resource changes"
}
check {
condition = context.plan.applyable
reason = "Plan must be applyable"
}
}
# Production requires manual approval after canary validation
```
For complete deployment configuration examples with all blocks, see `examples.md`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
# Linked Stacks Reference
Complete reference for linking Terraform Stacks together using published outputs and upstream inputs.
## Publish Output Block
Exports outputs from a Stack for consumption by other Stacks (linked Stacks).
###Syntax
```hcl
publish_output "<output_name>" {
type = <type>
value = <expression>
}
```
### Arguments
- **output_name** (label, required): Unique identifier for this published output
- **type** (required): Data type of the output
- **value** (required): Expression to export
### Accessing Deployment Outputs
Reference deployment outputs using: `deployment.<deployment_name>.<output_name>`
### Important Notes
- Must apply the Stack's deployment configuration before downstream Stacks can reference outputs
- Published outputs create a snapshot that other Stacks can read
- Changes to published outputs automatically trigger runs in downstream Stacks
### Examples
**Basic Published Output:**
```hcl
publish_output "vpc_id" {
type = string
value = deployment.network.vpc_id
}
publish_output "subnet_ids" {
type = list(string)
value = deployment.network.private_subnet_ids
}
```
**Multiple Deployment Outputs:**
```hcl
publish_output "regional_vpc_ids" {
type = map(string)
value = {
us_east = deployment.us_east.vpc_id
us_west = deployment.us_west.vpc_id
eu_west = deployment.eu_west.vpc_id
}
}
```
**Complex Output:**
```hcl
publish_output "database_config" {
type = object({
endpoint = string
port = number
name = string
})
value = {
endpoint = deployment.production.db_endpoint
port = deployment.production.db_port
name = deployment.production.db_name
}
}
```
**Regional Endpoints:**
```hcl
publish_output "api_endpoints" {
type = map(object({
url = string
region = string
}))
value = {
for env in ["dev", "staging", "prod"] : env => {
url = deployment[env].api_url
region = deployment[env].region
}
}
}
```
## Upstream Input Block
References published outputs from another Stack (linked Stacks).
### Syntax
```hcl
upstream_input "<input_name>" {
type = "stack"
source = "<stack_address>"
}
```
### Arguments
- **input_name** (label, required): Local name for this upstream input
- **type** (required): Must be "stack"
- **source** (required): Full Stack address in format: `app.terraform.io/<org>/<project>/<stack-name>`
### Accessing Upstream Outputs
Reference upstream outputs using: `upstream_input.<input_name>.<output_name>`
### Important Notes
- Creates a dependency on the upstream Stack
- Upstream Stack must have applied its deployment configuration
- Changes in upstream Stack automatically trigger downstream Stack runs
- Only works with Stacks in the same HCP Terraform project
### Examples
**Basic Upstream Reference:**
```hcl
upstream_input "network" {
type = "stack"
source = "app.terraform.io/my-org/my-project/networking-stack"
}
deployment "application" {
inputs = {
vpc_id = upstream_input.network.vpc_id
subnet_ids = upstream_input.network.subnet_ids
}
}
```
**Multiple Upstream Stacks:**
```hcl
upstream_input "network" {
type = "stack"
source = "app.terraform.io/my-org/my-project/network-stack"
}
upstream_input "database" {
type = "stack"
source = "app.terraform.io/my-org/my-project/database-stack"
}
deployment "application" {
inputs = {
vpc_id = upstream_input.network.vpc_id
subnet_ids = upstream_input.network.private_subnet_ids
database_endpoint = upstream_input.database.endpoint
database_credentials = upstream_input.database.credentials
}
}
```
**Regional Upstream Dependencies:**
```hcl
upstream_input "regional_network" {
type = "stack"
source = "app.terraform.io/my-org/my-project/regional-networks"
}
deployment "us_east_app" {
inputs = {
region = "us-east-1"
vpc_id = upstream_input.regional_network.regional_vpc_ids["us_east"]
subnet_ids = upstream_input.regional_network.regional_subnet_ids["us_east"]
}
}
```
## Complete Working Example
For a complete example showing full Stack configurations with all files (variables, providers, components, outputs, deployments) for both upstream and downstream Stacks, see the "Linked Stacks (Cross-Stack Dependencies)" section in `examples.md`.

View File

@@ -0,0 +1,671 @@
# Troubleshooting Reference
Common issues and solutions when working with Terraform Stacks.
## Table of Contents
1. [Configuration Issues](#configuration-issues)
2. [Deployment Issues](#deployment-issues)
3. [Provider and Authentication Issues](#provider-and-authentication-issues)
4. [Module Compatibility Issues](#module-compatibility-issues)
5. [State and Dependency Issues](#state-and-dependency-issues)
6. [API and CLI Issues](#api-and-cli-issues)
## Configuration Issues
### Circular Dependencies
**Issue:** Component A references Component B, and Component B references Component A.
**Error Message:**
```
Error: Cycle detected in component dependencies
```
**Solutions:**
1. **Break the circular reference** by refactoring components:
```hcl
# Before (circular dependency)
component "vpc" {
source = "./modules/vpc"
inputs = {
security_group_id = component.app.security_group_id # References app
}
}
component "app" {
source = "./modules/app"
inputs = {
vpc_id = component.vpc.vpc_id # References vpc
}
}
# After (broken circular reference)
component "vpc" {
source = "./modules/vpc"
inputs = {
# Remove reference to app
}
}
component "security_group" {
source = "./modules/security-group"
inputs = {
vpc_id = component.vpc.vpc_id
}
}
component "app" {
source = "./modules/app"
inputs = {
vpc_id = component.vpc.vpc_id
security_group_id = component.security_group.id
}
}
```
2. **Use intermediate components** to break the dependency chain
3. **Refactor modules** to remove the circular dependency at the module level
### Validation Errors on Variables
**Issue:** Variable block validation errors during `terraform stacks validate`.
**Error Message:**
```
Error: Unsupported argument
on variables.tfcomponent.hcl line 5:
5: validation {
Validation blocks are not supported in Stack configurations
```
**Solution:** Remove `validation` blocks from variable declarations. Stacks do not support validation blocks:
```hcl
# Incorrect
variable "instance_count" {
type = number
validation {
condition = var.instance_count > 0
error_message = "Instance count must be positive"
}
}
# Correct
variable "instance_count" {
type = number
description = "Number of instances (must be positive)"
}
```
Move validation logic into the underlying modules if needed.
### Missing Type in Variable Declarations
**Issue:** Variables fail validation when `type` is not specified.
**Error Message:**
```
Error: Missing required argument
on variables.tfcomponent.hcl line 3:
3: variable "region" {
The argument "type" is required in Stack variable declarations
```
**Solution:** Always specify `type` for variables - it's required in Stacks (unlike traditional Terraform):
```hcl
# Incorrect
variable "region" {
default = "us-west-1"
}
# Correct
variable "region" {
type = string
default = "us-west-1"
}
```
### Provider Configuration in Modules
**Issue:** Modules with embedded provider blocks cause errors.
**Error Message:**
```
Error: Provider configuration not allowed in module
Modules used with Terraform Stacks cannot contain provider blocks
```
**Solution:**
1. **Remove provider blocks from modules** - configure providers in Stack configuration instead
2. **Use modules that don't contain provider blocks** (most public registry modules are compatible)
3. **Fork and modify modules** if necessary to remove provider blocks
## Deployment Issues
### Cannot Destroy Deployment from UI
**Issue:** The HCP Terraform UI doesn't provide an option to destroy Stack deployments.
**Why:** Stack deployment destruction is only available through configuration, not the UI.
**Solution:** Set `destroy = true` in the deployment block and upload the configuration:
```hcl
deployment "old_environment" {
inputs = {
aws_region = "us-west-1"
instance_count = 2
role_arn = local.role_arn
identity_token = identity_token.aws.jwt
}
destroy = true # Marks deployment for destruction
}
```
**Workflow:**
1. Add `destroy = true` to the deployment block
2. Run `terraform stacks configuration upload`
3. HCP Terraform creates a destroy run automatically
4. Approve the destroy run (if auto-approve is not configured)
5. After destruction completes, remove the deployment block entirely
6. Upload configuration again to clean up the deployment definition
**Important:** You cannot destroy deployments from the UI. This is by design to prevent accidental destruction.
### Deployment Stuck in "Planning" State
**Issue:** Deployment remains in "planning" state indefinitely.
**Possible Causes:**
1. **Provider authentication failed** - Check OIDC configuration and IAM roles
2. **Module download failed** - Verify module sources are accessible
3. **Provider version conflict** - Check `.terraform.lock.hcl` matches required providers
**Diagnosis:**
```bash
# Get deployment step diagnostics
terraform stacks deployment-run list
# Note the run ID, then:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-runs/{run-id}/stack-deployment-steps" | \
jq '.data[] | {id, status: .attributes.status, component: .attributes["component-name"]}'
```
**Solutions:**
1. Check diagnostics for the stuck step
2. Verify provider authentication is configured correctly
3. Ensure all module sources are accessible
4. Check provider lock file matches required providers
### Deployment Requires Approval But No Approval Prompt
**Issue:** Deployment is waiting for approval but CLI doesn't show approval prompt.
**Why:** CLI monitoring commands are non-blocking and don't automatically prompt for approval.
**Solution:**
**Option 1: Approve via CLI**
```bash
# Approve all pending plans in a deployment run
terraform stacks deployment-run approve-all-plans -deployment-run-id=sdr-ABC123
# Or approve all plans in a deployment group
terraform stacks deployment-group approve-all-plans -deployment-group=canary
```
**Option 2: Configure auto-approve** (Premium feature)
```hcl
deployment_auto_approve "safe_changes" {
deployment_group = deployment_group.canary
check {
condition = context.plan.applyable
reason = "Plan must be successful"
}
}
```
## Provider and Authentication Issues
### OIDC Authentication Failing
**Issue:** Provider authentication fails with OIDC/workload identity.
**Error Messages:**
```
Error: Error assuming role with web identity
Error: Failed to retrieve credentials
Error: Invalid identity token
```
**Diagnosis Steps:**
1. **Verify identity token configuration:**
```hcl
# Check identity_token block exists
identity_token "aws" {
audience = ["aws.workload.identity"]
}
# Check deployment references the token
deployment "production" {
inputs = {
identity_token = identity_token.aws.jwt
}
}
```
2. **Verify provider configuration:**
```hcl
provider "aws" "this" {
config {
region = var.aws_region
assume_role_with_web_identity {
role_arn = var.role_arn
web_identity_token = var.identity_token
}
}
}
```
3. **Check IAM role trust policy:**
**AWS - Verify trust policy includes HCP Terraform:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/app.terraform.io"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"app.terraform.io:aud": "aws.workload.identity"
},
"StringLike": {
"app.terraform.io:sub": "organization:<org-name>:project:<project-name>:stack:<stack-name>:deployment:<deployment-name>"
}
}
}
]
}
```
**Azure - Verify federated credential:**
- Application ID matches the one in provider configuration
- Subject matches: `organization:<org>:project:<project>:stack:<stack>:deployment:<deployment>`
- Issuer is `https://app.terraform.io`
**GCP - Verify workload identity pool:**
- Provider configuration includes correct workload identity provider
- Service account has necessary IAM permissions
- Attribute mapping includes `google.subject` from token claims
**Solutions:**
1. Fix IAM role trust policy to include correct HCP Terraform OIDC provider
2. Ensure audience matches between identity_token block and IAM trust policy
3. Verify subject pattern matches your organization/project/stack/deployment names
4. Check that the role_arn is correct in provider configuration
### Provider Version Lock File Issues
**Issue:** Provider version conflicts or "could not retrieve provider" errors.
**Error Messages:**
```
Error: Failed to install provider
Error: Provider version not found
Error: Checksum mismatch for provider
```
**Solutions:**
1. **Regenerate provider lock file:**
```bash
terraform stacks providers-lock
```
2. **Add additional platforms** (if deploying from different OS):
```bash
terraform stacks providers-lock \
-platform=linux_amd64 \
-platform=darwin_amd64 \
-platform=darwin_arm64
```
3. **Verify required_providers block:**
```hcl
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.7.0" # Ensure version constraint is valid
}
}
```
4. **Commit `.terraform.lock.hcl`** to version control
## Module Compatibility Issues
### Public Registry Module Errors
**Issue:** Modules from the Terraform public registry cause errors during plan or apply.
**Common Errors:**
```
Error: Unsupported attribute
Error: Invalid reference
Error: Missing required argument
```
**Known Problematic Modules:**
- `terraform-aws-modules/alb/aws` - Some versions have compatibility issues
- `terraform-aws-modules/ecs-service/aws` - May have issues with certain configurations
**Solutions:**
1. **Test modules in dev deployment first** before using in production
2. **Check module compatibility** by reviewing recent issues on the module repository
3. **Use specific module versions** rather than latest:
```hcl
component "alb" {
source = "terraform-aws-modules/alb/aws"
version = "8.7.0" # Use specific version known to work
# ...
}
```
4. **Consider using raw resources** for critical infrastructure:
```hcl
# Instead of using a module that has issues
component "alb" {
source = "./modules/alb" # Create local module with raw resources
# ...
}
```
5. **Fork and fix modules** if you have the resources to maintain them
6. **Report compatibility issues** to module maintainers
### Local Module Not Found
**Issue:** Stack can't find local module sources.
**Error Message:**
```
Error: Module not found
Could not load module ./modules/vpc
```
**Solutions:**
1. **Verify module path is relative** to Stack root:
```hcl
# Correct
component "vpc" {
source = "./modules/vpc"
}
# Incorrect (absolute paths don't work)
component "vpc" {
source = "/Users/username/project/modules/vpc"
}
```
2. **Ensure module directory exists** with proper structure:
```
my-stack/
├── components.tfcomponent.hcl
└── modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
```
3. **Check file permissions** on module directories
## State and Dependency Issues
### Component Output Not Available
**Issue:** Component output is not available to referencing component.
**Error Message:**
```
Error: Reference to unknown component
Component "vpc" has not been defined
```
**Solutions:**
1. **Verify component exists** in configuration:
```hcl
component "vpc" {
source = "./modules/vpc"
# Must define component before referencing it
}
component "app" {
source = "./modules/app"
inputs = {
vpc_id = component.vpc.vpc_id # Now valid
}
}
```
2. **Check output is defined in module:**
```hcl
# In modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
```
3. **For components with for_each**, reference specific instance:
```hcl
component "regional" {
for_each = var.regions
# ...
}
component "app" {
inputs = {
# Correct - reference specific instance
vpc_id = component.regional["us-west-1"].vpc_id
# Incorrect - can't reference for_each component directly
# vpc_id = component.regional.vpc_id
}
}
```
### Deferred Changes Not Converging
**Issue:** Deployment with deferred changes doesn't complete after multiple iterations.
**Error Message:**
```
Error: Maximum deferred change iterations reached
```
**Cause:** Dependency cycle or values that never stabilize.
**Solutions:**
1. **Review component dependencies** for logical cycles
2. **Check for computed values that change on every run**
3. **Refactor to break dependency chain**
4. **Consider multi-stage deployments** if resources truly can't be created together
## API and CLI Issues
### Empty Diagnostics Response
**Issue:** API request for diagnostics returns empty results.
**Request:**
```bash
curl "https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/stack-diagnostics"
```
**Response:**
```json
{
"data": []
}
```
**Solution:** Add required `stack_deployment_step_id` query parameter:
```bash
curl "https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/stack-diagnostics?stack_deployment_step_id={step-id}"
```
### Cannot Retrieve Stack Outputs
**Issue:** No CLI command to retrieve Stack outputs after deployment.
**Why:** Currently no direct CLI command for outputs retrieval.
**Solution:** Use the artifacts API endpoint:
```bash
# Get final apply step ID first
APPLY_STEP=$(terraform stacks deployment-run list --json | \
jq -r '.[0].deployment_steps[] | select(.operation_type == "apply") | .id' | tail -1)
# Get outputs
curl -L -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/$APPLY_STEP/artifacts?name=apply-description" | \
jq -r '.outputs | to_entries | .[] | "\(.key): \(.value.change.after)"'
```
### CLI Watch Commands Hang in CI/CD
**Issue:** Commands like `terraform stacks deployment-run watch` never return in CI/CD pipelines.
**Why:** Watch commands stream output indefinitely and are designed for interactive use.
**Solution:** Use API polling instead of watch commands. See `api-monitoring.md` for complete workflow.
### Artifacts Endpoint Returns 404
**Issue:** Request to artifacts endpoint returns 404 Not Found.
**Possible Causes:**
1. **Step hasn't completed yet** - wait for step status to be "completed"
2. **Wrong artifact name** - use one of: plan-description, plan-debug-log, apply-description, apply-debug-log
3. **Invalid step ID** - verify step ID from deployment-steps endpoint
**Solution:**
```bash
# Check step status first
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}" | \
jq '.data.attributes.status'
# Only request artifacts when status is "completed"
if [ "$STATUS" = "completed" ]; then
curl -L -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/artifacts?name=apply-description"
fi
```
### HTTP 307 Redirect Not Followed
**Issue:** Artifacts endpoint returns redirect response instead of artifact content.
**Why:** The endpoint returns HTTP 307 redirect to the actual artifact URL.
**Solution:** Configure HTTP client to follow redirects:
```bash
# curl: Use -L flag
curl -L -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/artifacts?name=apply-description"
# Python requests: allow_redirects=True (default)
import requests
response = requests.get(url, headers=headers, allow_redirects=True)
# Node.js fetch: redirect: 'follow' (default)
const response = await fetch(url, {
headers: headers,
redirect: 'follow'
});
```
## Getting Additional Help
### Enable Debug Logging
For more detailed error information, enable debug logging:
```bash
# CLI commands
TF_LOG=DEBUG terraform stacks validate
TF_LOG=DEBUG terraform stacks configuration upload
# API artifacts
# Request the debug-log artifact instead of description
curl -L -H "Authorization: Bearer $TOKEN" \
"https://app.terraform.io/api/v2/stack-deployment-steps/{step-id}/artifacts?name=apply-debug-log"
```
### Check HCP Terraform Status
If experiencing widespread issues, check HCP Terraform status page:
- https://status.hashicorp.com
### Review Configuration Version
List recent configurations to identify when issues started:
```bash
terraform stacks configuration list
```
### Contact Support
For issues not covered here:
1. Gather relevant error messages and diagnostics
2. Note the configuration sequence number
3. Include deployment run IDs
4. Contact HashiCorp Support with details

View File

@@ -0,0 +1,164 @@
---
name: terraform-style-guide-security
description: Generate Terraform HCL code following HashiCorp's security practices
---
# Terraform Style Guide - Security
When generating code, apply security hardening:
- Enable encryption at rest by default
- Configure private networking where applicable
- Apply principle of least privilege for security groups
- Enable logging and monitoring
- Never hardcode credentials or secrets
- Mark sensitive outputs with `sensitive = true`
- Use `ephemeral` resources and write-only attributes
for sensitive data when possible
## Example: Secure S3 Bucket
```hcl
resource "aws_s3_bucket" "data" {
bucket = "${var.project}-${var.environment}-data"
tags = local.common_tags
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
```
## Ephemeral resources
Ephemeral resources prevent sensitive data being stored in state.
For more information on ephemeral resources, see the
[Terraform documentation](https://developer.hashicorp.com/terraform/language/block/ephemeral).
Before you generate code for an ephemeral resource, check that the Terraform
version is greater than or equal to 1.11.0.
Then, follow this priority order for managing sensitive attributes:
1. **First priority: Native secrets manager integration**
If a resource has the ability to automatically manage a sensitive attribute by
storing it in a secrets manager (e.g., AWS Secrets Manager, Azure Key Vault),
use that configuration. This is the preferred approach.
```hcl
# Bad
resource "aws_rds_cluster" "example" {
cluster_identifier = "example"
database_name = "test"
master_username = "test"
master_password = var.db_master_password
}
# Good, managed by AWS Secrets Manager by default
resource "aws_rds_cluster" "test" {
cluster_identifier = "example"
database_name = "test"
manage_master_user_password = true
master_username = "test"
}
```
2. **Second priority: Write-only attributes with ephemeral resources**
If a resource has a write-only attribute but no native secrets manager integration,
use an `ephemeral` resource for the sensitive data and pass that to the write-only
attribute. Default the write-only version to 1.
```hcl
# Bad
resource "random_password" "password" {
length = 16
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
resource "vault_kv_secret_v2" "example" {
mount = vault_mount.kvv2.path
name = "secret"
data_json = jsonencode(
{
password = "${random_password.password.result}",
}
)
}
# Good
ephemeral "random_password" "password" {
length = 16
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
resource "vault_kv_secret_v2" "example" {
mount = vault_mount.kvv2.path
name = "secret"
data_json_wo = jsonencode(
{
password = "${ephemeral.random_password.password.result}",
}
)
data_json_wo_version = 1
}
```
If you need to retrieve a secret from a secrets manager to pass
to a resource, use the `ephemeral` version of the resource to
retrieve the secret and pass it to another resource.
```hcl
# Good
ephemeral "vault_kv_secret_v2" "db_secret" {
mount = vault_mount.kvv2.path
mount_id = vault_mount.kvv2.id
name = vault_kv_secret_v2.db_root.name
}
resource "vault_database_secret_backend_connection" "postgres" {
backend = vault_mount.db.path
name = "postrgres-db"
allowed_roles = ["*"]
postgresql {
connection_url = "postgresql://{{username}}:{{password}}@localhost:5432/postgres"
password_authentication = ""
username = "postgres"
password_wo = tostring(ephemeral.vault_kv_secret_v2.db_secret.data.password)
password_wo_version = 1
}
}
```
3. **Last resort: Regular resources**
Only use a regular resource that has sensitive data written to state if neither of the above
options are available, resource does not offer a write-only attribute or ephemeral resource
alternative, or the Terraform version is less than 1.11.0.

View File

@@ -0,0 +1,314 @@
---
name: terraform-style-guide
description: Generate Terraform HCL code following HashiCorp's official style conventions and best practices. Use when writing, reviewing, or generating Terraform configurations.
---
# Terraform Style Guide
Generate and maintain Terraform code following HashiCorp's official style conventions and best practices.
**Reference:** [HashiCorp Terraform Style Guide](https://developer.hashicorp.com/terraform/language/style)
## Code Generation Strategy
When generating Terraform code:
1. Start with provider configuration and version constraints
2. Create data sources before dependent resources
3. Build resources in dependency order
4. Add outputs for key resource attributes
5. Use variables for all configurable values
## File Organization
| File | Purpose |
|------|---------|
| `terraform.tf` | Terraform and provider version requirements |
| `providers.tf` | Provider configurations |
| `main.tf` | Primary resources and data sources |
| `variables.tf` | Input variable declarations (alphabetical) |
| `outputs.tf` | Output value declarations (alphabetical) |
| `locals.tf` | Local value declarations |
### Example Structure
```hcl
# terraform.tf
terraform {
required_version = ">= 1.14"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# variables.tf
variable "environment" {
description = "Target deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
# locals.tf
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = merge(local.common_tags, {
Name = "${var.project_name}-${var.environment}-vpc"
})
}
# outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
```
## Code Formatting
### Indentation and Alignment
- Use **two spaces** per nesting level (no tabs)
- Align equals signs for consecutive arguments
```hcl
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
subnet_id = "subnet-12345678"
tags = {
Name = "web-server"
Environment = "production"
}
}
```
### Block Organization
Arguments precede blocks, with meta-arguments first:
```hcl
resource "aws_instance" "example" {
# Meta-arguments
count = 3
# Arguments
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
# Blocks
root_block_device {
volume_size = 20
}
# Lifecycle last
lifecycle {
create_before_destroy = true
}
}
```
## Naming Conventions
- Use **lowercase with underscores** for all names
- Use **descriptive nouns** excluding the resource type
- Be specific and meaningful
- Resource names must be singular, not plural
- Default to `main` for resources where a specific descriptive name is redundant or unavailable, provided only one instance exists
```hcl
# Bad
resource "aws_instance" "webAPI-aws-instance" {}
resource "aws_instance" "web_apis" {}
variable "name" {}
# Good
resource "aws_instance" "web_api" {}
resource "aws_vpc" "main" {}
variable "application_name" {}
```
## Variables
Every variable must include `type` and `description`:
```hcl
variable "instance_type" {
description = "EC2 instance type for the web server"
type = string
default = "t2.micro"
validation {
condition = contains(["t2.micro", "t2.small", "t2.medium"], var.instance_type)
error_message = "Instance type must be t2.micro, t2.small, or t2.medium."
}
}
variable "database_password" {
description = "Password for the database admin user"
type = string
sensitive = true
}
```
## Outputs
Every output must include `description`:
```hcl
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "database_password" {
description = "Database administrator password"
value = aws_db_instance.main.password
sensitive = true
}
```
## Dynamic Resource Creation
### Prefer for_each over count
```hcl
# Bad - count for multiple resources
resource "aws_instance" "web" {
count = var.instance_count
tags = { Name = "web-${count.index}" }
}
# Good - for_each with named instances
variable "instance_names" {
type = set(string)
default = ["web-1", "web-2", "web-3"]
}
resource "aws_instance" "web" {
for_each = var.instance_names
tags = { Name = each.key }
}
```
### count for Conditional Creation
```hcl
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "high-cpu-usage"
threshold = 80
}
```
## Security Best Practices
Refer to SECURITY.md. It includes guidance on encrypting resources,
preventing sensitive data in state, and secure configurations.
## Version Pinning
```hcl
terraform {
required_version = ">= 1.14"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
```
Use the latest major version of each provider and the latest minor version of
Terraform, unless otherwise constrained by a dependency lock file or by other
modules used by the configuration.
**Version constraint operators:**
- `= 1.0.0` - Exact version
- `>= 1.0.0` - Greater than or equal
- `~> 1.0` - Allow rightmost component to increment
- `>= 1.0, < 2.0` - Version range
## Provider Configuration
```hcl
provider "aws" {
region = "us-west-2"
default_tags {
tags = {
ManagedBy = "Terraform"
Project = var.project_name
}
}
}
# Aliased provider for multi-region
provider "aws" {
alias = "east"
region = "us-east-1"
}
```
## Version Control
**Never commit:**
- `terraform.tfstate`, `terraform.tfstate.backup`
- `.terraform/` directory
- `*.tfplan`
- `.tfvars` files with sensitive data
**Always commit:**
- All `.tf` configuration files
- `.terraform.lock.hcl` (dependency lock file)
## Validation Tools
Run before committing:
```bash
terraform fmt -recursive
terraform validate
```
Additional tools:
- `tflint` - Linting and best practices
- `checkov` / `tfsec` - Security scanning
## Code Review Checklist
- [ ] Code formatted with `terraform fmt`
- [ ] Configuration validated with `terraform validate`
- [ ] Files organized according to standard structure
- [ ] All variables have type and description
- [ ] All outputs have descriptions
- [ ] Resource names use descriptive nouns with underscores
- [ ] Version constraints pinned explicitly
- [ ] Sensitive values marked with `sensitive = true`
- [ ] No hardcoded credentials or secrets
- [ ] Security best practices applied
---
*Based on: [HashiCorp Terraform Style Guide](https://developer.hashicorp.com/terraform/language/style)*

View File

@@ -0,0 +1,451 @@
---
name: terraform-test
description: Comprehensive guide for writing and running Terraform tests. Use when creating test files (.tftest.hcl), writing test scenarios with run blocks, validating infrastructure behavior with assertions, mocking providers and data sources, testing module outputs and resource configurations, or troubleshooting Terraform test syntax and execution.
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.0.2"
---
# Terraform Test
Terraform's built-in testing framework validates that configuration updates don't introduce breaking changes. Tests run against temporary resources, protecting existing infrastructure and state files.
## Reference Files
- `references/MOCK_PROVIDERS.md` — Mock provider syntax, common defaults, when to use mocks (Terraform 1.7.0+ only — skip if the user's version is below 1.7)
- `references/CI_CD.md` — GitHub Actions and GitLab CI pipeline examples
- `references/EXAMPLES.md` — Complete example test suite (unit, integration, and mock tests for a VPC module)
Read the relevant reference file when the user asks about mocking, CI/CD integration, or wants a full example.
## Core Concepts
- **Test file** (`.tftest.hcl` / `.tftest.json`): Contains `run` blocks that validate your configuration
- **Run block**: A single test scenario with optional variables, providers, and assertions
- **Assert block**: Conditions that must be true for the test to pass
- **Mock provider**: Simulates provider behavior without real infrastructure (Terraform 1.7.0+)
- **Test modes**: `apply` (default, creates real resources) or `plan` (validates logic only)
## File Structure
```
my-module/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
├── defaults_unit_test.tftest.hcl # plan mode — fast, no resources
├── validation_unit_test.tftest.hcl # plan mode
└── full_stack_integration_test.tftest.hcl # apply mode — creates real resources
```
Use `*_unit_test.tftest.hcl` for plan-mode tests and `*_integration_test.tftest.hcl` for apply-mode tests so they can be filtered separately in CI.
## Test File Structure
```hcl
# Optional: test-wide settings
test {
parallel = true # Enable parallel execution for all run blocks (default: false)
}
# Optional: file-level variables (highest precedence, override all other sources)
variables {
aws_region = "us-west-2"
instance_type = "t2.micro"
}
# Optional: provider configuration
provider "aws" {
region = var.aws_region
}
# Required: at least one run block
run "test_default_configuration" {
command = plan
assert {
condition = aws_instance.example.instance_type == "t2.micro"
error_message = "Instance type should be t2.micro by default"
}
}
```
## Run Block
```hcl
run "test_name" {
command = plan # or apply (default)
parallel = true # optional, since v1.9.0
# Override file-level variables
variables {
instance_type = "t3.large"
}
# Reference a specific module
module {
source = "./modules/vpc" # local or registry only (not git/http)
version = "5.0.0" # registry modules only
}
# Control state isolation
state_key = "shared_state" # since v1.9.0
# Plan behavior
plan_options {
mode = refresh-only # or normal (default)
refresh = true
replace = [aws_instance.example]
target = [aws_instance.example]
}
# Assertions
assert {
condition = aws_instance.example.id != ""
error_message = "Instance should have a valid ID"
}
# Expected failures (test passes if these fail)
expect_failures = [
var.instance_count
]
}
```
## Common Test Patterns
### Validate outputs
```hcl
run "test_outputs" {
command = plan
assert {
condition = output.vpc_id != null
error_message = "VPC ID output must be defined"
}
assert {
condition = can(regex("^vpc-", output.vpc_id))
error_message = "VPC ID should start with 'vpc-'"
}
}
```
### Conditional resources
```hcl
run "test_nat_gateway_disabled" {
command = plan
variables {
create_nat_gateway = false
}
assert {
condition = length(aws_nat_gateway.main) == 0
error_message = "NAT gateway should not be created when disabled"
}
}
```
### Resource counts
```hcl
run "test_resource_count" {
command = plan
variables {
instance_count = 3
}
assert {
condition = length(aws_instance.workers) == 3
error_message = "Should create exactly 3 worker instances"
}
}
```
### Tags
```hcl
run "test_resource_tags" {
command = plan
variables {
common_tags = {
Environment = "production"
ManagedBy = "Terraform"
}
}
assert {
condition = aws_instance.example.tags["Environment"] == "production"
error_message = "Environment tag should be set correctly"
}
assert {
condition = aws_instance.example.tags["ManagedBy"] == "Terraform"
error_message = "ManagedBy tag should be set correctly"
}
}
```
### Data sources
```hcl
run "test_data_source_lookup" {
command = plan
assert {
condition = data.aws_ami.ubuntu.id != ""
error_message = "Should find a valid Ubuntu AMI"
}
assert {
condition = can(regex("^ami-", data.aws_ami.ubuntu.id))
error_message = "AMI ID should be in correct format"
}
}
```
### Validation rules
```hcl
run "test_invalid_environment" {
command = plan
variables {
environment = "invalid"
}
expect_failures = [
var.environment
]
}
```
### Sequential tests with dependencies
```hcl
run "setup_vpc" {
command = apply
assert {
condition = output.vpc_id != ""
error_message = "VPC should be created"
}
}
run "test_subnet_in_vpc" {
command = plan
variables {
vpc_id = run.setup_vpc.vpc_id
}
assert {
condition = aws_subnet.example.vpc_id == run.setup_vpc.vpc_id
error_message = "Subnet should be in the VPC from setup_vpc"
}
}
```
### Plan options (refresh-only, targeted)
```hcl
run "test_refresh_only" {
command = plan
plan_options {
mode = refresh-only
}
assert {
condition = aws_instance.example.tags["Environment"] == "production"
error_message = "Tags should be refreshed correctly"
}
}
run "test_specific_resource" {
command = plan
plan_options {
target = [aws_instance.example]
}
assert {
condition = aws_instance.example.instance_type == "t2.micro"
error_message = "Targeted resource should be planned"
}
}
```
### Parallel modules
```hcl
run "test_networking_module" {
command = plan
parallel = true
module {
source = "./modules/networking"
}
assert {
condition = output.vpc_id != ""
error_message = "VPC should be created"
}
}
run "test_compute_module" {
command = plan
parallel = true
module {
source = "./modules/compute"
}
assert {
condition = output.instance_id != ""
error_message = "Instance should be created"
}
}
```
### State key sharing
```hcl
run "create_foundation" {
command = apply
state_key = "foundation"
assert {
condition = aws_vpc.main.id != ""
error_message = "Foundation VPC should be created"
}
}
run "create_application" {
command = apply
state_key = "foundation"
variables {
vpc_id = run.create_foundation.vpc_id
}
assert {
condition = aws_instance.app.vpc_id == run.create_foundation.vpc_id
error_message = "Application should use foundation VPC"
}
}
```
### Cleanup ordering (S3 objects before bucket)
```hcl
run "create_bucket" {
command = apply
assert {
condition = aws_s3_bucket.example.id != ""
error_message = "Bucket should be created"
}
}
run "add_objects" {
command = apply
assert {
condition = length(aws_s3_object.files) > 0
error_message = "Objects should be added"
}
}
# Cleanup destroys in reverse: objects first, then bucket
```
### Multiple aliased providers
```hcl
provider "aws" {
alias = "primary"
region = "us-west-2"
}
provider "aws" {
alias = "secondary"
region = "us-east-1"
}
run "test_with_specific_provider" {
command = plan
providers = {
aws = provider.aws.secondary
}
assert {
condition = aws_instance.example.availability_zone == "us-east-1a"
error_message = "Instance should be in us-east-1 region"
}
}
```
### Complex conditions
```hcl
assert {
condition = alltrue([
for subnet in aws_subnet.private :
can(regex("^10\\.0\\.", subnet.cidr_block))
])
error_message = "All private subnets should use 10.0.0.0/8 CIDR range"
}
```
## Cleanup
Resources are destroyed in **reverse run block order** after test completion. This matters for dependencies (e.g., S3 objects before bucket). Use `terraform test -no-cleanup` to skip cleanup for debugging.
## Running Tests
```bash
terraform test # all tests
terraform test tests/defaults.tftest.hcl # specific file
terraform test -filter=test_vpc_configuration # by run block name
terraform test -test-directory=integration-tests # custom directory
terraform test -verbose # detailed output
terraform test -no-cleanup # skip resource cleanup
```
## Best Practices
1. **Naming**: `*_unit_test.tftest.hcl` for plan mode, `*_integration_test.tftest.hcl` for apply mode
2. **Test naming**: Use descriptive run block names that explain the scenario being tested
3. **Default to plan**: Use `command = plan` unless you need to test real resource behavior
4. **Use mocks** for external dependencies — faster and no credentials needed (see `references/MOCK_PROVIDERS.md`)
5. **Error messages**: Make them specific enough to diagnose failures without running the test again
6. **Negative tests**: Use `expect_failures` to verify validation rules reject bad inputs
7. **Variable coverage**: Test different variable combinations to validate all code paths — test variables have the highest precedence and override all other sources
8. **Module sources**: Test files only support local paths and registry modules — not git or HTTP URLs
9. **Parallel execution**: Use `parallel = true` for independent tests with different state files
10. **Cleanup**: Integration tests destroy resources in reverse run block order automatically; use `-no-cleanup` for debugging
11. **CI/CD**: Run unit tests on every PR, integration tests on merge (see `references/CI_CD.md`)
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Assertion failures | Use `-verbose` to see actual vs expected values |
| Missing credentials | Use mock providers for unit tests |
| Unsupported module source | Convert git/HTTP sources to local modules |
| Tests interfering | Use `state_key` or separate modules for isolation |
| Slow tests | Use `command = plan` and mocks; run integration tests separately |
## References
- [Terraform Testing Documentation](https://developer.hashicorp.com/terraform/language/tests)
- [Terraform Test Command](https://developer.hashicorp.com/terraform/cli/commands/test)
- [Testing Best Practices](https://developer.hashicorp.com/terraform/language/tests/best-practices)

View File

@@ -0,0 +1,80 @@
# CI/CD Integration
## GitHub Actions
```yaml
name: Terraform Tests
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.0
- run: terraform fmt -check -recursive
- run: terraform init
- run: terraform validate
- name: Run unit tests (plan mode, no credentials needed)
run: terraform test -filter=unit_test -verbose
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.0
- run: terraform init
- name: Run integration tests
run: terraform test -filter=integration_test -verbose
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
```
## GitLab CI
```yaml
stages:
- validate
- test
terraform-unit-tests:
image: hashicorp/terraform:1.9
stage: validate
before_script:
- terraform init
script:
- terraform fmt -check -recursive
- terraform validate
- terraform test -filter=unit_test -verbose
terraform-integration-tests:
image: hashicorp/terraform:1.9
stage: test
before_script:
- terraform init
script:
- terraform test -filter=integration_test -verbose
only:
- main
```
## Recommended CI Strategy
- Run unit tests (plan mode + mock tests) on every PR — fast, no credentials needed
- Run integration tests only on merge to main or nightly — requires cloud credentials
- Use `-filter=unit_test` / `-filter=integration_test` to separate test types based on naming convention
- Store cloud credentials as CI secrets, never in code

View File

@@ -0,0 +1,314 @@
# Example Test Suite
Complete example testing a VPC module with unit, integration, and mock tests.
## Unit Tests (Plan Mode)
```hcl
# tests/vpc_module_unit_test.tftest.hcl
variables {
environment = "test"
aws_region = "us-west-2"
}
run "test_defaults" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
vpc_name = "test-vpc"
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR should match input"
}
assert {
condition = aws_vpc.main.enable_dns_hostnames == true
error_message = "DNS hostnames should be enabled by default"
}
assert {
condition = aws_vpc.main.tags["Name"] == "test-vpc"
error_message = "VPC name tag should match input"
}
}
run "test_subnets" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
vpc_name = "test-vpc"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24"]
}
assert {
condition = length(aws_subnet.public) == 2
error_message = "Should create 2 public subnets"
}
assert {
condition = length(aws_subnet.private) == 2
error_message = "Should create 2 private subnets"
}
assert {
condition = alltrue([
for subnet in aws_subnet.private :
subnet.map_public_ip_on_launch == false
])
error_message = "Private subnets should not assign public IPs"
}
}
run "test_outputs" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
vpc_name = "test-vpc"
}
assert {
condition = output.vpc_id != ""
error_message = "VPC ID output should not be empty"
}
assert {
condition = can(regex("^vpc-", output.vpc_id))
error_message = "VPC ID should have correct format"
}
assert {
condition = output.vpc_cidr == "10.0.0.0/16"
error_message = "VPC CIDR output should match input"
}
}
run "test_invalid_cidr" {
command = plan
variables {
vpc_cidr = "invalid"
vpc_name = "test-vpc"
}
expect_failures = [
var.vpc_cidr
]
}
```
## Integration Tests (Apply Mode)
```hcl
# tests/vpc_module_integration_test.tftest.hcl
variables {
environment = "integration-test"
aws_region = "us-west-2"
}
run "integration_test_vpc_creation" {
# command defaults to apply — creates real AWS resources
variables {
vpc_cidr = "10.100.0.0/16"
vpc_name = "integration-test-vpc"
}
assert {
condition = aws_vpc.main.id != ""
error_message = "VPC should be created with valid ID"
}
assert {
condition = aws_vpc.main.state == "available"
error_message = "VPC should be in available state"
}
}
```
## Mock Tests (Plan Mode, No Credentials)
```hcl
# tests/vpc_module_mock_test.tftest.hcl
mock_provider "aws" {
mock_resource "aws_instance" {
defaults = {
id = "i-1234567890abcdef0"
instance_type = "t2.micro"
ami = "ami-12345678"
public_ip = "203.0.113.1"
private_ip = "10.0.1.100"
}
}
mock_resource "aws_vpc" {
defaults = {
id = "vpc-12345678"
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
}
mock_resource "aws_subnet" {
defaults = {
id = "subnet-12345678"
vpc_id = "vpc-12345678"
cidr_block = "10.0.1.0/24"
availability_zone = "us-west-2a"
map_public_ip_on_launch = false
}
}
mock_data "aws_ami" {
defaults = {
id = "ami-0c55b159cbfafe1f0"
name = "ubuntu-focal-20.04-amd64"
}
}
mock_data "aws_availability_zones" {
defaults = {
names = ["us-west-2a", "us-west-2b", "us-west-2c"]
}
}
}
run "test_instance_with_mocks" {
command = plan
variables {
instance_type = "t2.micro"
ami_id = "ami-12345678"
}
assert {
condition = aws_instance.example.instance_type == "t2.micro"
error_message = "Instance type should match input variable"
}
assert {
condition = aws_instance.example.id == "i-1234567890abcdef0"
error_message = "Mock should return consistent instance ID"
}
}
run "test_data_source_with_mocks" {
command = plan
assert {
condition = data.aws_ami.ubuntu.id == "ami-0c55b159cbfafe1f0"
error_message = "Mock data source should return predictable AMI ID"
}
assert {
condition = length(data.aws_availability_zones.available.names) == 3
error_message = "Should return 3 mocked availability zones"
}
assert {
condition = contains(data.aws_availability_zones.available.names, "us-west-2a")
error_message = "Should include us-west-2a in mocked zones"
}
}
run "test_outputs_with_mocks" {
command = plan
assert {
condition = output.vpc_id == "vpc-12345678"
error_message = "VPC ID output should match mocked value"
}
assert {
condition = can(regex("^vpc-", output.vpc_id))
error_message = "VPC ID output should have correct format"
}
}
run "test_conditional_resources_with_mocks" {
command = plan
variables {
create_bastion = true
create_nat_gateway = false
}
assert {
condition = length(aws_instance.bastion) == 1
error_message = "Bastion should be created when enabled"
}
assert {
condition = length(aws_nat_gateway.nat) == 0
error_message = "NAT gateway should not be created when disabled"
}
}
run "test_tag_inheritance_with_mocks" {
command = plan
variables {
common_tags = {
Environment = "test"
ManagedBy = "Terraform"
}
}
assert {
condition = alltrue([
for key in keys(var.common_tags) :
contains(keys(aws_instance.example.tags), key)
])
error_message = "All common tags should be present on instance"
}
}
run "test_invalid_cidr_with_mocks" {
command = plan
variables {
vpc_cidr = "invalid"
}
expect_failures = [
var.vpc_cidr
]
}
run "setup_vpc_with_mocks" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
vpc_name = "test-vpc"
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR should match input"
}
}
run "test_subnet_references_vpc_with_mocks" {
command = plan
variables {
vpc_id = run.setup_vpc_with_mocks.vpc_id
subnet_cidr = "10.0.1.0/24"
}
assert {
condition = aws_subnet.example.vpc_id == run.setup_vpc_with_mocks.vpc_id
error_message = "Subnet should reference VPC from previous run"
}
}
```

View File

@@ -0,0 +1,171 @@
# Mock Providers
Mock providers simulate provider behavior without creating real infrastructure (Terraform 1.7.0+). Use them for fast, credential-free unit tests.
## Basic Mock Provider
```hcl
mock_provider "aws" {
mock_resource "aws_instance" {
defaults = {
id = "i-1234567890abcdef0"
instance_type = "t2.micro"
ami = "ami-12345678"
public_ip = "203.0.113.1"
private_ip = "10.0.1.100"
}
}
mock_data "aws_ami" {
defaults = {
id = "ami-0c55b159cbfafe1f0"
}
}
mock_data "aws_availability_zones" {
defaults = {
names = ["us-west-2a", "us-west-2b", "us-west-2c"]
}
}
}
run "test_with_mocks" {
command = plan # Mocks only work with plan mode
assert {
condition = aws_instance.example.id == "i-1234567890abcdef0"
error_message = "Mock instance ID should match"
}
}
```
## Aliased Mock Provider
```hcl
mock_provider "aws" {
alias = "mocked"
mock_resource "aws_s3_bucket" {
defaults = {
id = "test-bucket-12345"
arn = "arn:aws:s3:::test-bucket-12345"
}
}
}
run "test_with_aliased_mock" {
command = plan
providers = {
aws = provider.aws.mocked
}
assert {
condition = aws_s3_bucket.example.id == "test-bucket-12345"
error_message = "Bucket ID should match mock"
}
}
```
## Common Mock Defaults
```hcl
mock_provider "aws" {
mock_resource "aws_instance" {
defaults = {
id = "i-1234567890abcdef0"
arn = "arn:aws:ec2:us-west-2:123456789012:instance/i-1234567890abcdef0"
instance_type = "t2.micro"
ami = "ami-12345678"
availability_zone = "us-west-2a"
subnet_id = "subnet-12345678"
vpc_security_group_ids = ["sg-12345678"]
associate_public_ip_address = true
public_ip = "203.0.113.1"
private_ip = "10.0.1.100"
tags = {}
}
}
mock_resource "aws_vpc" {
defaults = {
id = "vpc-12345678"
arn = "arn:aws:ec2:us-west-2:123456789012:vpc/vpc-12345678"
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
instance_tenancy = "default"
tags = {}
}
}
mock_resource "aws_subnet" {
defaults = {
id = "subnet-12345678"
arn = "arn:aws:ec2:us-west-2:123456789012:subnet/subnet-12345678"
vpc_id = "vpc-12345678"
cidr_block = "10.0.1.0/24"
availability_zone = "us-west-2a"
map_public_ip_on_launch = false
tags = {}
}
}
mock_resource "aws_s3_bucket" {
defaults = {
id = "test-bucket-12345"
arn = "arn:aws:s3:::test-bucket-12345"
bucket = "test-bucket-12345"
bucket_domain_name = "test-bucket-12345.s3.amazonaws.com"
region = "us-west-2"
tags = {}
}
}
mock_data "aws_ami" {
defaults = {
id = "ami-0c55b159cbfafe1f0"
name = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-20210430"
architecture = "x86_64"
root_device_type = "ebs"
virtualization_type = "hvm"
}
}
mock_data "aws_availability_zones" {
defaults = {
names = ["us-west-2a", "us-west-2b", "us-west-2c"]
zone_ids = ["usw2-az1", "usw2-az2", "usw2-az3"]
}
}
mock_data "aws_vpc" {
defaults = {
id = "vpc-12345678"
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
}
}
```
## When to Use Mocks
**Good fit:**
- Testing Terraform logic, conditionals, `for_each`/`count` expressions
- Validating variable transformations and output calculations
- Local development without cloud credentials
- Fast CI/CD feedback loops
**Not a good fit:**
- Validating actual provider API behavior
- Testing real resource creation side effects
- End-to-end integration testing
## Limitations
- **Plan mode only** — mocks don't work with `command = apply`
- Mock defaults may not reflect real computed attribute values
- Mocks need manual updates when provider schemas change
- Can't test real resource dependencies or timing

View File

@@ -0,0 +1 @@
../../.agents/skills/new-terraform-provider

View File

@@ -0,0 +1 @@
../../.agents/skills/provider-actions

View File

@@ -0,0 +1 @@
../../.agents/skills/provider-docs

View File

@@ -0,0 +1 @@
../../.agents/skills/provider-resources

View File

@@ -0,0 +1 @@
../../.agents/skills/provider-test-patterns

View File

@@ -0,0 +1 @@
../../.agents/skills/pulumi-terraform-to-pulumi

View File

@@ -0,0 +1 @@
../../.agents/skills/push-to-registry

View File

@@ -0,0 +1 @@
../../.agents/skills/refactor-module

Some files were not shown because too many files have changed in this diff Show More