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:
24
.agents/skills/new-terraform-provider/SKILL.md
Normal file
24
.agents/skills/new-terraform-provider/SKILL.md
Normal 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 ./...`
|
||||||
43
.agents/skills/new-terraform-provider/assets/main.go
Normal file
43
.agents/skills/new-terraform-provider/assets/main.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
529
.agents/skills/provider-actions/SKILL.md
Normal file
529
.agents/skills/provider-actions/SKILL.md
Normal 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, ¬Found) {
|
||||||
|
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`)
|
||||||
67
.agents/skills/provider-docs/SKILL.md
Normal file
67
.agents/skills/provider-docs/SKILL.md
Normal 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.
|
||||||
7
.agents/skills/provider-docs/agents/openai.yaml
Normal file
7
.agents/skills/provider-docs/agents/openai.yaml
Normal 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."
|
||||||
@@ -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
|
||||||
599
.agents/skills/provider-resources/SKILL.md
Normal file
599
.agents/skills/provider-resources/SKILL.md
Normal 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, ¬Found) {
|
||||||
|
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, ¬Found) {
|
||||||
|
// 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)
|
||||||
414
.agents/skills/provider-test-patterns/SKILL.md
Normal file
414
.agents/skills/provider-test-patterns/SKILL.md
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
231
.agents/skills/provider-test-patterns/references/checks.md
Normal file
231
.agents/skills/provider-test-patterns/references/checks.md
Normal 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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
208
.agents/skills/provider-test-patterns/references/ephemeral.md
Normal file
208
.agents/skills/provider-test-patterns/references/ephemeral.md
Normal 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")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
101
.agents/skills/provider-test-patterns/references/sweepers.md
Normal file
101
.agents/skills/provider-test-patterns/references/sweepers.md
Normal 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
|
||||||
|
```
|
||||||
80
.agents/skills/pulumi-terraform-to-pulumi/SKILL.md
Normal file
80
.agents/skills/pulumi-terraform-to-pulumi/SKILL.md
Normal 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.
|
||||||
|
|
||||||
@@ -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."
|
||||||
26
.agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml
Normal file
26
.agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml
Normal 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"
|
||||||
203
.agents/skills/push-to-registry/SKILL.md
Normal file
203
.agents/skills/push-to-registry/SKILL.md
Normal 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)
|
||||||
538
.agents/skills/refactor-module/SKILL.md
Normal file
538
.agents/skills/refactor-module/SKILL.md
Normal 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 |
|
||||||
41
.agents/skills/run-acceptance-tests/SKILL.md
Normal file
41
.agents/skills/run-acceptance-tests/SKILL.md
Normal 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.
|
||||||
3
.agents/skills/terraform-policy/.gitignore
vendored
Normal file
3
.agents/skills/terraform-policy/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
41
.agents/skills/terraform-policy/README.md
Normal file
41
.agents/skills/terraform-policy/README.md
Normal 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.
|
||||||
46
.agents/skills/terraform-policy/SKILL.md
Normal file
46
.agents/skills/terraform-policy/SKILL.md
Normal 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
|
||||||
|
```
|
||||||
27
.agents/skills/terraform-policy/evals/eval.yaml
Normal file
27
.agents/skills/terraform-policy/evals/eval.yaml
Normal 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"
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
30
.agents/skills/terraform-policy/examples/README.md
Normal file
30
.agents/skills/terraform-policy/examples/README.md
Normal 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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
1750
.agents/skills/terraform-policy/references/tfpolicy-author.md
Normal file
1750
.agents/skills/terraform-policy/references/tfpolicy-author.md
Normal file
File diff suppressed because it is too large
Load Diff
2111
.agents/skills/terraform-policy/references/tfpolicy-test.md
Normal file
2111
.agents/skills/terraform-policy/references/tfpolicy-test.md
Normal file
File diff suppressed because it is too large
Load Diff
2024
.agents/skills/terraform-policy/references/verified-syntax.md
Normal file
2024
.agents/skills/terraform-policy/references/verified-syntax.md
Normal file
File diff suppressed because it is too large
Load Diff
372
.agents/skills/terraform-search-import/SKILL.md
Normal file
372
.agents/skills/terraform-search-import/SKILL.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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
|
||||||
480
.agents/skills/terraform-stacks/SKILL.md
Normal file
480
.agents/skills/terraform-stacks/SKILL.md
Normal 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
|
||||||
543
.agents/skills/terraform-stacks/references/api-monitoring.md
Normal file
543
.agents/skills/terraform-stacks/references/api-monitoring.md
Normal 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.
|
||||||
476
.agents/skills/terraform-stacks/references/component-blocks.md
Normal file
476
.agents/skills/terraform-stacks/references/component-blocks.md
Normal 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
|
||||||
|
}
|
||||||
|
```
|
||||||
391
.agents/skills/terraform-stacks/references/deployment-blocks.md
Normal file
391
.agents/skills/terraform-stacks/references/deployment-blocks.md
Normal 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`.
|
||||||
1529
.agents/skills/terraform-stacks/references/examples.md
Normal file
1529
.agents/skills/terraform-stacks/references/examples.md
Normal file
File diff suppressed because it is too large
Load Diff
187
.agents/skills/terraform-stacks/references/linked-stacks.md
Normal file
187
.agents/skills/terraform-stacks/references/linked-stacks.md
Normal 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`.
|
||||||
671
.agents/skills/terraform-stacks/references/troubleshooting.md
Normal file
671
.agents/skills/terraform-stacks/references/troubleshooting.md
Normal 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
|
||||||
164
.agents/skills/terraform-style-guide/SECURITY.md
Normal file
164
.agents/skills/terraform-style-guide/SECURITY.md
Normal 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.
|
||||||
314
.agents/skills/terraform-style-guide/SKILL.md
Normal file
314
.agents/skills/terraform-style-guide/SKILL.md
Normal 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)*
|
||||||
451
.agents/skills/terraform-test/SKILL.md
Normal file
451
.agents/skills/terraform-test/SKILL.md
Normal 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)
|
||||||
80
.agents/skills/terraform-test/references/CI_CD.md
Normal file
80
.agents/skills/terraform-test/references/CI_CD.md
Normal 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
|
||||||
314
.agents/skills/terraform-test/references/EXAMPLES.md
Normal file
314
.agents/skills/terraform-test/references/EXAMPLES.md
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
171
.agents/skills/terraform-test/references/MOCK_PROVIDERS.md
Normal file
171
.agents/skills/terraform-test/references/MOCK_PROVIDERS.md
Normal 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
|
||||||
1
.claude/skills/new-terraform-provider
Symbolic link
1
.claude/skills/new-terraform-provider
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/new-terraform-provider
|
||||||
1
.claude/skills/provider-actions
Symbolic link
1
.claude/skills/provider-actions
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/provider-actions
|
||||||
1
.claude/skills/provider-docs
Symbolic link
1
.claude/skills/provider-docs
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/provider-docs
|
||||||
1
.claude/skills/provider-resources
Symbolic link
1
.claude/skills/provider-resources
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/provider-resources
|
||||||
1
.claude/skills/provider-test-patterns
Symbolic link
1
.claude/skills/provider-test-patterns
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/provider-test-patterns
|
||||||
1
.claude/skills/pulumi-terraform-to-pulumi
Symbolic link
1
.claude/skills/pulumi-terraform-to-pulumi
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/pulumi-terraform-to-pulumi
|
||||||
1
.claude/skills/push-to-registry
Symbolic link
1
.claude/skills/push-to-registry
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/push-to-registry
|
||||||
1
.claude/skills/refactor-module
Symbolic link
1
.claude/skills/refactor-module
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/refactor-module
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user