From a6d8aa8b52d89bbf0fac5b9a78054c86b42abe0c Mon Sep 17 00:00:00 2001 From: Max Vojtkov Date: Sun, 9 Aug 2026 12:17:26 +0300 Subject: [PATCH] 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. --- .../skills/new-terraform-provider/SKILL.md | 24 + .../new-terraform-provider/assets/main.go | 43 + .agents/skills/provider-actions/SKILL.md | 529 +++++ .agents/skills/provider-docs/SKILL.md | 67 + .../skills/provider-docs/agents/openai.yaml | 7 + .../references/hashicorp-provider-docs.md | 65 + .agents/skills/provider-resources/SKILL.md | 599 +++++ .../skills/provider-test-patterns/SKILL.md | 414 ++++ .../references/checks.md | 231 ++ .../references/ephemeral.md | 208 ++ .../references/sweepers.md | 101 + .../pulumi-terraform-to-pulumi/SKILL.md | 80 + .../agents/openai.yaml | 4 + .../pulumi-terraform-to-pulumi/use_cases.yaml | 26 + .agents/skills/push-to-registry/SKILL.md | 203 ++ .agents/skills/refactor-module/SKILL.md | 538 +++++ .agents/skills/run-acceptance-tests/SKILL.md | 41 + .agents/skills/terraform-policy/.gitignore | 3 + .agents/skills/terraform-policy/README.md | 41 + .agents/skills/terraform-policy/SKILL.md | 46 + .../skills/terraform-policy/evals/eval.yaml | 27 + .../evals/tasks/negative-trigger-1.yaml | 17 + .../evals/tasks/positive-trigger-1.yaml | 17 + .../evals/tasks/positive-trigger-2.yaml | 17 + .../terraform-policy/examples/README.md | 30 + .../cloudfront-associated-with-waf/README.md | 19 + .../cloudfront-associated-with-waf.policy.hcl | 14 + .../cloudfront-associated-with-waf.sentinel | 61 + .../README.md | 17 + ...-server-side-encryption-enabled.policy.hcl | 13 + ...il-server-side-encryption-enabled.sentinel | 53 + .../README.md | 17 + ...dpoint-should-be-ssl-configured.policy.hcl | 13 + ...endpoint-should-be-ssl-configured.sentinel | 55 + .../dms-endpoints-should-use-ssl/README.md | 18 + .../dms-endpoints-should-use-ssl.policy.hcl | 14 + .../dms-endpoints-should-use-ssl.sentinel | 50 + .../README.md | 20 + ...work-acl-should-have-subnet-ids.policy.hcl | 24 + ...etwork-acl-should-have-subnet-ids.sentinel | 91 + .../README.md | 22 + ...fault-security-group-no-traffic.policy.hcl | 20 + ...default-security-group-no-traffic.sentinel | 94 + .../README.md | 17 + ...nt-should-enforce-user-identity.policy.hcl | 9 + ...oint-should-enforce-user-identity.sentinel | 50 + .../README.md | 17 + ...p-encryption-at-transit-enabled.policy.hcl | 9 + ...oup-encryption-at-transit-enabled.sentinel | 52 + .../elasticsearch-encrypted-at-rest/README.md | 18 + ...elasticsearch-encrypted-at-rest.policy.hcl | 14 + .../elasticsearch-encrypted-at-rest.sentinel | 54 + .../elasticsearch-https-required/README.md | 18 + .../elasticsearch-https-required.policy.hcl | 26 + .../elasticsearch-https-required.sentinel | 68 + .../elasticsearch-in-vpc-only/README.md | 18 + .../elasticsearch-in-vpc-only.policy.hcl | 14 + .../elasticsearch-in-vpc-only.sentinel | 64 + .../README.md | 20 + ...bus-should-have-attached-policy.policy.hcl | 22 + ...t-bus-should-have-attached-policy.sentinel | 76 + .../README.md | 25 + ...lock-public-access-bucket-level.policy.hcl | 27 + ...-block-public-access-bucket-level.sentinel | 103 + .../README.md | 18 + ...should-have-object-lock-enabled.policy.hcl | 23 + ...t-should-have-object-lock-enabled.sentinel | 100 + .../README.md | 18 + ...ger-auto-rotation-enabled-check.policy.hcl | 22 + ...nager-auto-rotation-enabled-check.sentinel | 73 + .../README.md | 17 + ...s-state-machine-logging-enabled.policy.hcl | 15 + ...ons-state-machine-logging-enabled.sentinel | 56 + .../references/tfpolicy-author.md | 1750 ++++++++++++++ .../references/tfpolicy-test.md | 2111 +++++++++++++++++ .../references/verified-syntax.md | 2024 ++++++++++++++++ .../skills/terraform-search-import/SKILL.md | 372 +++ .../references/MANUAL-IMPORT.md | 113 + .../scripts/list_resources.sh | 38 + .agents/skills/terraform-stacks/SKILL.md | 480 ++++ .../references/api-monitoring.md | 543 +++++ .../references/component-blocks.md | 476 ++++ .../references/deployment-blocks.md | 391 +++ .../terraform-stacks/references/examples.md | 1529 ++++++++++++ .../references/linked-stacks.md | 187 ++ .../references/troubleshooting.md | 671 ++++++ .../skills/terraform-style-guide/SECURITY.md | 164 ++ .agents/skills/terraform-style-guide/SKILL.md | 314 +++ .agents/skills/terraform-test/SKILL.md | 451 ++++ .../skills/terraform-test/references/CI_CD.md | 80 + .../terraform-test/references/EXAMPLES.md | 314 +++ .../references/MOCK_PROVIDERS.md | 171 ++ .claude/skills/new-terraform-provider | 1 + .claude/skills/provider-actions | 1 + .claude/skills/provider-docs | 1 + .claude/skills/provider-resources | 1 + .claude/skills/provider-test-patterns | 1 + .claude/skills/pulumi-terraform-to-pulumi | 1 + .claude/skills/push-to-registry | 1 + .claude/skills/refactor-module | 1 + .claude/skills/run-acceptance-tests | 1 + .claude/skills/terraform-policy | 1 + .claude/skills/terraform-search-import | 1 + .claude/skills/terraform-stacks | 1 + .claude/skills/terraform-style-guide | 1 + .claude/skills/terraform-test | 1 + .gitignore | 23 + LICENSE | 356 +++ Makefile | 65 + README.md | 440 ++++ docs/data-sources/application.md | 31 + docs/data-sources/environment.md | 29 + docs/data-sources/project.md | 39 + docs/data-sources/projects.md | 30 + docs/data-sources/servers.md | 30 + docs/index.md | 22 + docs/resources/application.md | 128 + docs/resources/certificate.md | 35 + docs/resources/compose.md | 76 + docs/resources/destination.md | 39 + docs/resources/domain.md | 46 + docs/resources/environment.md | 35 + docs/resources/mariadb.md | 66 + docs/resources/mongo.md | 65 + docs/resources/mount.md | 39 + docs/resources/mysql.md | 66 + docs/resources/port.md | 31 + docs/resources/postgres.md | 65 + docs/resources/project.md | 35 + docs/resources/redirect.md | 31 + docs/resources/redis.md | 63 + docs/resources/registry.md | 34 + docs/resources/security.md | 30 + docs/resources/ssh_key.md | 38 + examples/complete/main.tf | 200 ++ examples/complete/outputs.tf | 19 + examples/complete/variables.tf | 14 + go.mod | 64 + go.sum | 246 ++ internal/client/client.go | 280 +++ internal/provider/data_sources.go | 467 ++++ internal/provider/generic.go | 508 ++++ internal/provider/provider.go | 171 ++ internal/provider/provider_test.go | 293 +++ internal/provider/resource_account.go | 190 ++ internal/provider/resource_application.go | 257 ++ internal/provider/resource_compose.go | 144 ++ internal/provider/resource_databases.go | 406 ++++ internal/provider/resource_environment.go | 49 + internal/provider/resource_networking.go | 254 ++ internal/provider/resource_project.go | 87 + internal/provider/schema_helpers.go | 223 ++ internal/tfmap/tfmap.go | 555 +++++ internal/tfmap/tfmap_test.go | 134 ++ main.go | 30 + modules/web-service/main.tf | 113 + modules/web-service/outputs.tf | 29 + modules/web-service/variables.tf | 186 ++ shim/shim.go | 16 + skills-lock.json | 89 + 160 files changed, 24260 insertions(+) create mode 100644 .agents/skills/new-terraform-provider/SKILL.md create mode 100644 .agents/skills/new-terraform-provider/assets/main.go create mode 100644 .agents/skills/provider-actions/SKILL.md create mode 100644 .agents/skills/provider-docs/SKILL.md create mode 100644 .agents/skills/provider-docs/agents/openai.yaml create mode 100644 .agents/skills/provider-docs/references/hashicorp-provider-docs.md create mode 100644 .agents/skills/provider-resources/SKILL.md create mode 100644 .agents/skills/provider-test-patterns/SKILL.md create mode 100644 .agents/skills/provider-test-patterns/references/checks.md create mode 100644 .agents/skills/provider-test-patterns/references/ephemeral.md create mode 100644 .agents/skills/provider-test-patterns/references/sweepers.md create mode 100644 .agents/skills/pulumi-terraform-to-pulumi/SKILL.md create mode 100644 .agents/skills/pulumi-terraform-to-pulumi/agents/openai.yaml create mode 100644 .agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml create mode 100644 .agents/skills/push-to-registry/SKILL.md create mode 100644 .agents/skills/refactor-module/SKILL.md create mode 100644 .agents/skills/run-acceptance-tests/SKILL.md create mode 100644 .agents/skills/terraform-policy/.gitignore create mode 100644 .agents/skills/terraform-policy/README.md create mode 100644 .agents/skills/terraform-policy/SKILL.md create mode 100644 .agents/skills/terraform-policy/evals/eval.yaml create mode 100644 .agents/skills/terraform-policy/evals/tasks/negative-trigger-1.yaml create mode 100644 .agents/skills/terraform-policy/evals/tasks/positive-trigger-1.yaml create mode 100644 .agents/skills/terraform-policy/evals/tasks/positive-trigger-2.yaml create mode 100644 .agents/skills/terraform-policy/examples/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel create mode 100644 .agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/README.md create mode 100644 .agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl create mode 100644 .agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel create mode 100644 .agents/skills/terraform-policy/references/tfpolicy-author.md create mode 100644 .agents/skills/terraform-policy/references/tfpolicy-test.md create mode 100644 .agents/skills/terraform-policy/references/verified-syntax.md create mode 100644 .agents/skills/terraform-search-import/SKILL.md create mode 100644 .agents/skills/terraform-search-import/references/MANUAL-IMPORT.md create mode 100644 .agents/skills/terraform-search-import/scripts/list_resources.sh create mode 100644 .agents/skills/terraform-stacks/SKILL.md create mode 100644 .agents/skills/terraform-stacks/references/api-monitoring.md create mode 100644 .agents/skills/terraform-stacks/references/component-blocks.md create mode 100644 .agents/skills/terraform-stacks/references/deployment-blocks.md create mode 100644 .agents/skills/terraform-stacks/references/examples.md create mode 100644 .agents/skills/terraform-stacks/references/linked-stacks.md create mode 100644 .agents/skills/terraform-stacks/references/troubleshooting.md create mode 100644 .agents/skills/terraform-style-guide/SECURITY.md create mode 100644 .agents/skills/terraform-style-guide/SKILL.md create mode 100644 .agents/skills/terraform-test/SKILL.md create mode 100644 .agents/skills/terraform-test/references/CI_CD.md create mode 100644 .agents/skills/terraform-test/references/EXAMPLES.md create mode 100644 .agents/skills/terraform-test/references/MOCK_PROVIDERS.md create mode 120000 .claude/skills/new-terraform-provider create mode 120000 .claude/skills/provider-actions create mode 120000 .claude/skills/provider-docs create mode 120000 .claude/skills/provider-resources create mode 120000 .claude/skills/provider-test-patterns create mode 120000 .claude/skills/pulumi-terraform-to-pulumi create mode 120000 .claude/skills/push-to-registry create mode 120000 .claude/skills/refactor-module create mode 120000 .claude/skills/run-acceptance-tests create mode 120000 .claude/skills/terraform-policy create mode 120000 .claude/skills/terraform-search-import create mode 120000 .claude/skills/terraform-stacks create mode 120000 .claude/skills/terraform-style-guide create mode 120000 .claude/skills/terraform-test create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/data-sources/application.md create mode 100644 docs/data-sources/environment.md create mode 100644 docs/data-sources/project.md create mode 100644 docs/data-sources/projects.md create mode 100644 docs/data-sources/servers.md create mode 100644 docs/index.md create mode 100644 docs/resources/application.md create mode 100644 docs/resources/certificate.md create mode 100644 docs/resources/compose.md create mode 100644 docs/resources/destination.md create mode 100644 docs/resources/domain.md create mode 100644 docs/resources/environment.md create mode 100644 docs/resources/mariadb.md create mode 100644 docs/resources/mongo.md create mode 100644 docs/resources/mount.md create mode 100644 docs/resources/mysql.md create mode 100644 docs/resources/port.md create mode 100644 docs/resources/postgres.md create mode 100644 docs/resources/project.md create mode 100644 docs/resources/redirect.md create mode 100644 docs/resources/redis.md create mode 100644 docs/resources/registry.md create mode 100644 docs/resources/security.md create mode 100644 docs/resources/ssh_key.md create mode 100644 examples/complete/main.tf create mode 100644 examples/complete/outputs.tf create mode 100644 examples/complete/variables.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/client.go create mode 100644 internal/provider/data_sources.go create mode 100644 internal/provider/generic.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go create mode 100644 internal/provider/resource_account.go create mode 100644 internal/provider/resource_application.go create mode 100644 internal/provider/resource_compose.go create mode 100644 internal/provider/resource_databases.go create mode 100644 internal/provider/resource_environment.go create mode 100644 internal/provider/resource_networking.go create mode 100644 internal/provider/resource_project.go create mode 100644 internal/provider/schema_helpers.go create mode 100644 internal/tfmap/tfmap.go create mode 100644 internal/tfmap/tfmap_test.go create mode 100644 main.go create mode 100644 modules/web-service/main.tf create mode 100644 modules/web-service/outputs.tf create mode 100644 modules/web-service/variables.tf create mode 100644 shim/shim.go create mode 100644 skills-lock.json diff --git a/.agents/skills/new-terraform-provider/SKILL.md b/.agents/skills/new-terraform-provider/SKILL.md new file mode 100644 index 0000000..6695bdc --- /dev/null +++ b/.agents/skills/new-terraform-provider/SKILL.md @@ -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 ./...` diff --git a/.agents/skills/new-terraform-provider/assets/main.go b/.agents/skills/new-terraform-provider/assets/main.go new file mode 100644 index 0000000..b8c4d6a --- /dev/null +++ b/.agents/skills/new-terraform-provider/assets/main.go @@ -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()) + } +} diff --git a/.agents/skills/provider-actions/SKILL.md b/.agents/skills/provider-actions/SKILL.md new file mode 100644 index 0000000..0e53d4a --- /dev/null +++ b/.agents/skills/provider-actions/SKILL.md @@ -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// +├── _action.go # Action implementation +├── _action_test.go # Action tests +└── service_package_gen.go # Auto-generated service registration +``` + +Documentation structure: +``` +website/docs/actions/ +└── _.html.markdown # User-facing documentation +``` + +Changelog entry: +``` +.changelog/ +└── .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().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/ +``` + +Run specific action tests: +```bash +TF_ACC=1 go test ./internal/service/ -run TestAccServiceAction_ -v +``` + +Run sweep to clean up test resources: +```bash +TF_ACC=1 go test ./internal/service/ -sweep= -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/.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/` +- [ ] Code formatted: `make fmt` +- [ ] Documentation formatted: `terrafmt fmt website/docs/actions/.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`) diff --git a/.agents/skills/provider-docs/SKILL.md b/.agents/skills/provider-docs/SKILL.md new file mode 100644 index 0000000..d681cae --- /dev/null +++ b/.agents/skills/provider-docs/SKILL.md @@ -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/.md.tmpl` + - `docs/resources/.md.tmpl` + - `docs/ephemeral-resources/.md.tmpl` + - `docs/list-resources/.md.tmpl` + - `docs/functions/.md.tmpl` + - `docs/guides/.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 +``` +- 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. diff --git a/.agents/skills/provider-docs/agents/openai.yaml b/.agents/skills/provider-docs/agents/openai.yaml new file mode 100644 index 0000000..434ee58 --- /dev/null +++ b/.agents/skills/provider-docs/agents/openai.yaml @@ -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." diff --git a/.agents/skills/provider-docs/references/hashicorp-provider-docs.md b/.agents/skills/provider-docs/references/hashicorp-provider-docs.md new file mode 100644 index 0000000..559e486 --- /dev/null +++ b/.agents/skills/provider-docs/references/hashicorp-provider-docs.md @@ -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/.md.tmpl` +- `docs/resources/.md.tmpl` +- `docs/ephemeral-resources/.md.tmpl` +- `docs/list-resources/.md.tmpl` +- `docs/functions/.md.tmpl` +- `docs/guides/.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 +``` + +Run from repository root: + +```bash +go generate ./... +``` + +Alternative direct execution: + +```bash +go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --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 diff --git a/.agents/skills/provider-resources/SKILL.md b/.agents/skills/provider-resources/SKILL.md new file mode 100644 index 0000000..7d93de3 --- /dev/null +++ b/.agents/skills/provider-resources/SKILL.md @@ -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// +├── .go # Resource implementation +├── _test.go # Acceptance tests +├── _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/ +└── _.html.markdown # Resource documentation + +website/docs/d/ +└── _.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/ + +# Run acceptance tests +TF_ACC=1 go test ./internal/service/ -run TestAccExample -v -timeout 60m + +# Run with specific provider version +TF_ACC=1 go test ./internal/service/ -run TestAccExample -v + +# Run sweeper to clean up +TF_ACC=1 go test ./internal/service/ -sweep= -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) diff --git a/.agents/skills/provider-test-patterns/SKILL.md b/.agents/skills/provider-test-patterns/SKILL.md new file mode 100644 index 0000000..32a06c9 --- /dev/null +++ b/.agents/skills/provider-test-patterns/SKILL.md @@ -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") + } +} +``` diff --git a/.agents/skills/provider-test-patterns/references/checks.md b/.agents/skills/provider-test-patterns/references/checks.md new file mode 100644 index 0000000..cd968c0 --- /dev/null +++ b/.agents/skills/provider-test-patterns/references/checks.md @@ -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(), + }, + }, + }, +}, +``` diff --git a/.agents/skills/provider-test-patterns/references/ephemeral.md b/.agents/skills/provider-test-patterns/references/ephemeral.md new file mode 100644 index 0000000..2352121 --- /dev/null +++ b/.agents/skills/provider-test-patterns/references/ephemeral.md @@ -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")), + }, + }, +}, +``` diff --git a/.agents/skills/provider-test-patterns/references/sweepers.md b/.agents/skills/provider-test-patterns/references/sweepers.md new file mode 100644 index 0000000..f5f1079 --- /dev/null +++ b/.agents/skills/provider-test-patterns/references/sweepers.md @@ -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 +``` diff --git a/.agents/skills/pulumi-terraform-to-pulumi/SKILL.md b/.agents/skills/pulumi-terraform-to-pulumi/SKILL.md new file mode 100644 index 0000000..57827c0 --- /dev/null +++ b/.agents/skills/pulumi-terraform-to-pulumi/SKILL.md @@ -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. + diff --git a/.agents/skills/pulumi-terraform-to-pulumi/agents/openai.yaml b/.agents/skills/pulumi-terraform-to-pulumi/agents/openai.yaml new file mode 100644 index 0000000..cab22ce --- /dev/null +++ b/.agents/skills/pulumi-terraform-to-pulumi/agents/openai.yaml @@ -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." diff --git a/.agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml b/.agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml new file mode 100644 index 0000000..1c3a0e0 --- /dev/null +++ b/.agents/skills/pulumi-terraform-to-pulumi/use_cases.yaml @@ -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" diff --git a/.agents/skills/push-to-registry/SKILL.md b/.agents/skills/push-to-registry/SKILL.md new file mode 100644 index 0000000..88a0619 --- /dev/null +++ b/.agents/skills/push-to-registry/SKILL.md @@ -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) diff --git a/.agents/skills/refactor-module/SKILL.md b/.agents/skills/refactor-module/SKILL.md new file mode 100644 index 0000000..ff412a3 --- /dev/null +++ b/.agents/skills/refactor-module/SKILL.md @@ -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 | diff --git a/.agents/skills/run-acceptance-tests/SKILL.md b/.agents/skills/run-acceptance-tests/SKILL.md new file mode 100644 index 0000000..847d4e3 --- /dev/null +++ b/.agents/skills/run-acceptance-tests/SKILL.md @@ -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. diff --git a/.agents/skills/terraform-policy/.gitignore b/.agents/skills/terraform-policy/.gitignore new file mode 100644 index 0000000..2348c91 --- /dev/null +++ b/.agents/skills/terraform-policy/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +__pycache__/ +*.pyc diff --git a/.agents/skills/terraform-policy/README.md b/.agents/skills/terraform-policy/README.md new file mode 100644 index 0000000..6f5955c --- /dev/null +++ b/.agents/skills/terraform-policy/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/SKILL.md b/.agents/skills/terraform-policy/SKILL.md new file mode 100644 index 0000000..45caf2b --- /dev/null +++ b/.agents/skills/terraform-policy/SKILL.md @@ -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 +``` diff --git a/.agents/skills/terraform-policy/evals/eval.yaml b/.agents/skills/terraform-policy/evals/eval.yaml new file mode 100644 index 0000000..0fe1b35 --- /dev/null +++ b/.agents/skills/terraform-policy/evals/eval.yaml @@ -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" diff --git a/.agents/skills/terraform-policy/evals/tasks/negative-trigger-1.yaml b/.agents/skills/terraform-policy/evals/tasks/negative-trigger-1.yaml new file mode 100644 index 0000000..8d3ef05 --- /dev/null +++ b/.agents/skills/terraform-policy/evals/tasks/negative-trigger-1.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" + diff --git a/.agents/skills/terraform-policy/evals/tasks/positive-trigger-1.yaml b/.agents/skills/terraform-policy/evals/tasks/positive-trigger-1.yaml new file mode 100644 index 0000000..2e57546 --- /dev/null +++ b/.agents/skills/terraform-policy/evals/tasks/positive-trigger-1.yaml @@ -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" + diff --git a/.agents/skills/terraform-policy/evals/tasks/positive-trigger-2.yaml b/.agents/skills/terraform-policy/evals/tasks/positive-trigger-2.yaml new file mode 100644 index 0000000..4f66ac3 --- /dev/null +++ b/.agents/skills/terraform-policy/evals/tasks/positive-trigger-2.yaml @@ -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" + diff --git a/.agents/skills/terraform-policy/examples/README.md b/.agents/skills/terraform-policy/examples/README.md new file mode 100644 index 0000000..dd19658 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/README.md @@ -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` - the actual Sentinel policy file included for comparison +- `.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. diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/README.md b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/README.md new file mode 100644 index 0000000..e318225 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl new file mode 100644 index 0000000..fb4d6f5 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel new file mode 100644 index 0000000..154a163 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/README.md b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/README.md new file mode 100644 index 0000000..a571254 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl new file mode 100644 index 0000000..2194f45 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel new file mode 100644 index 0000000..c809193 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/README.md b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/README.md new file mode 100644 index 0000000..56b8ffa --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl new file mode 100644 index 0000000..bacf4d7 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel new file mode 100644 index 0000000..ce408f9 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/README.md b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/README.md new file mode 100644 index 0000000..c9a2b4c --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl new file mode 100644 index 0000000..834d1f5 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel new file mode 100644 index 0000000..088623a --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md new file mode 100644 index 0000000..d75c43b --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl new file mode 100644 index 0000000..8118d7f --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel new file mode 100644 index 0000000..7affbaa --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md new file mode 100644 index 0000000..df41dcf --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl new file mode 100644 index 0000000..80ce96a --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel new file mode 100644 index 0000000..387f0cd --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/README.md b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/README.md new file mode 100644 index 0000000..1714b16 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl new file mode 100644 index 0000000..3491282 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel new file mode 100644 index 0000000..950fd02 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md new file mode 100644 index 0000000..f18a495 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl new file mode 100644 index 0000000..313e4c8 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel new file mode 100644 index 0000000..46977d4 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/README.md b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/README.md new file mode 100644 index 0000000..0923ffe --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl new file mode 100644 index 0000000..5ddf8ec --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel new file mode 100644 index 0000000..18e5084 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/README.md b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/README.md new file mode 100644 index 0000000..dc30d68 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl new file mode 100644 index 0000000..6d7621d --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl @@ -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'" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel new file mode 100644 index 0000000..8e45233 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/README.md b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/README.md new file mode 100644 index 0000000..3e3f833 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl new file mode 100644 index 0000000..9e33238 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel new file mode 100644 index 0000000..ad2dd49 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md new file mode 100644 index 0000000..0b80325 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl new file mode 100644 index 0000000..dae62f8 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel new file mode 100644 index 0000000..33aac7d --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/README.md b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/README.md new file mode 100644 index 0000000..3251abe --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl new file mode 100644 index 0000000..3a3c2a3 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel new file mode 100644 index 0000000..101f3a5 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md new file mode 100644 index 0000000..a0bab85 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl new file mode 100644 index 0000000..f77d723 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel new file mode 100644 index 0000000..8982b73 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md new file mode 100644 index 0000000..242a34f --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md @@ -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 diff --git a/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl new file mode 100644 index 0000000..f673c72 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel new file mode 100644 index 0000000..8118c02 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/README.md b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/README.md new file mode 100644 index 0000000..8b14e64 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/README.md @@ -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. diff --git a/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl new file mode 100644 index 0000000..316ef27 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl @@ -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" + } +} diff --git a/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel new file mode 100644 index 0000000..dc28cb7 --- /dev/null +++ b/.agents/skills/terraform-policy/examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel @@ -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 +} diff --git a/.agents/skills/terraform-policy/references/tfpolicy-author.md b/.agents/skills/terraform-policy/references/tfpolicy-author.md new file mode 100644 index 0000000..1d1d8b4 --- /dev/null +++ b/.agents/skills/terraform-policy/references/tfpolicy-author.md @@ -0,0 +1,1750 @@ +--- +name: tfpolicy-author +description: Expert agent for authoring Terraform Policies — from natural-language requirements or Sentinel source. Covers the full workflow write new policies, convert existing Sentinel policies, parameterize with inputs, structure cross-resource checks, apply operation scoping, and produce remediation-focused error messages. +license: MPL-2.0 +metadata: + copyright: Copyright IBM Corp. 2026 + version: "0.2.0" +--- + +# tfpolicy-author + +## Description +Expert agent for writing Terraform Policies — from either a natural-language requirement or an existing Sentinel `.sentinel` source. Covers the full authoring and conversion workflow: translate requirements or Sentinel logic into `resource_policy`, `module_policy`, or `provider_policy` HCL with correct cross-resource patterns, operation scoping, `core::` functions, parameterized inputs, and remediation-focused error messages. + +> **Note:** This skill supersedes `sentinel-to-tfpolicy`. All Sentinel conversion knowledge is now consolidated here to ensure consistent behaviour when generating policies from Sentinel sources. + +## Use When +- The user describes an enforcement rule and wants a `.policy.hcl` file ("block public RDS", "require encryption", "deny instance types outside an allowlist"). +- The user has a Sentinel `.sentinel` file (or snippet) and wants the Terraform Policy equivalent. +- The user is migrating a Sentinel policy library to tfpolicy and needs per-policy assessment. +- The user is writing a new `resource_policy`, `module_policy`, or `provider_policy` block. +- The user is asking about tfpolicy *language* features — `filter`, `locals`, `enforce`, `input`, `operations`, `prior_attrs`, `core::*` functions, list comprehensions, version constraints, time/date functions. +- The user is asking how to structure a policy that depends on related resources via `core::getresources()` or `core::getdatasource()`. +- The user is comparing Sentinel and tfpolicy capabilities ("can I express X in tfpolicy?"). + +**Do not use this skill when:** +- The user is writing or debugging a `.policytest.hcl` test file — use [`tfpolicy-test`](tfpolicy-test.md). + +## Capabilities + +### 1. Write Policies from User Intent +Turn natural-language requirements into `resource_policy`, `module_policy`, or `provider_policy` blocks with appropriate filters, locals, enforce blocks, and remediation-focused error messages. + +### 2. Convert Sentinel Policies to Terraform Policy +Translate Sentinel constructs into tfpolicy equivalents, flag non-convertible patterns with practical alternatives, produce idiomatic `.policy.hcl` from existing Sentinel sources, and apply a quality label to each conversion. + +### 3. Apply Operation Scoping Correctly +Use `operations = ["create", "update", "delete"]` and `prior_attrs.` to scope policies to the right plan actions and read pre-change state when relevant. + +### 4. Parameterize with `input` Blocks +Replace hardcoded allowlists, version constraints, and thresholds with `input` blocks so policy sets can override values per environment. + +### 5. Structure Cross-Resource Checks Safely +Use `core::getresources()` at the **top level** for plan-time joins, with value-based filters when the filter is a known literal or existing ID. Use inline `core::getresources()` inside `resource_policy` for apply-time parent+child lookups, when the filter depends on the current resource's own attribute (e.g. `{bucket = attrs.id}`). Consult the decision table (line 232) to choose the correct pattern; both top-level and inline are first-class options for their respective scenarios. Understand when cross-references will be unresolved at plan time. + +### 6. Surface Runtime Pitfalls Up Front +Steer the user away from documented runtime hazards — `meta.address` is undefined in `resource_policy`, `core::try()` defaults can silently mask non-compliant resources, sets must be converted to lists for indexing, multi-line boolean expressions break the parser, etc. + +## Knowledge Base + +### Policy Types +```hcl +resource_policy "" "" { } +module_policy "" "" { } +provider_policy "" "" { } +``` + +### Core Structure +```hcl +resource_policy "aws_ebs_volume" "encryption_check" { + # Optional: pre-filter resources before evaluation + filter = attrs.encrypted != null + + # Optional: locals for readable logic + locals { + encrypted = core::try(attrs.encrypted, false) + } + + # One or more enforce blocks + enforce { + condition = local.encrypted == true + error_message = "EBS volumes must have encryption enabled." + } +} +``` + +### Available Attribute Surfaces + +| Surface | Available in | Notes | +| --- | --- | --- | +| `attrs.*` | resource / module / provider | Planned values for the current target. Wrap optional fields in `core::try()`. | +| `prior_attrs.*` | resource_policy with `operations` ⊉ `["create"]` | Pre-change values. Use for `delete` and `update` scopes. | +| `meta.provider_type` | resource_policy | e.g. `"aws"`. Useful for cross-provider wildcard rules. | +| `meta.tfe_workspace.tags[""]` | resource_policy only | Workspace-scoped routing (env, team, etc.). ❌ Not available in module_policy or provider_policy. | +| `meta.address` | ❌ | **UNDEFINED** in `resource_policy` real-plan evaluation. Never interpolate it into `error_message`. | +| `input.` | all | Values from `input {}` blocks; overridable per policy set. | + +### Operation Scoping and `prior_attrs` + +```hcl +# Skip destroy +resource_policy "tfe_workspace" "require_tags" { + operations = ["create", "update"] + enforce { + condition = core::length(core::try(attrs.tag_names, [])) > 0 + error_message = "Workspace must have at least one tag." + } +} + +# Delete-gate +resource_policy "tfe_workspace" "deny_delete_without_tag" { + operations = ["delete"] # prior_attrs available when "create" not in operations + locals { + prior_tag_names = core::try(prior_attrs.tag_names, []) + } + enforce { + condition = core::contains(local.prior_tag_names, "delete") + error_message = "Add 'delete' tag before destroying the workspace." + } +} +``` + +**Rules:** +- `operations = ["create", "update"]` — fires on create/update, skips destroy. +- `operations = ["delete"]` — fires only on destroy; `prior_attrs` holds pre-change state. +- `operations = ["update"]` — fires only on updates; `prior_attrs` available. +- Default (no `operations`) = create and update (never destroy). +- `prior_attrs` is only accessible when `"create"` is NOT in `operations`. + +### `input` Blocks — Parameterization + +```hcl +input "allowed_instance_types" { + type = list(string) + default = ["t3.micro", "t3.small", "t3.medium"] +} + +resource_policy "aws_instance" "allowed_types" { + enforce { + condition = core::contains(input.allowed_instance_types, attrs.instance_type) + error_message = "Instance type '${attrs.instance_type}' is not in the allowed list." + } +} +``` + +Policy sets can override `input` defaults without editing the policy file. Use `input` for: allowlists, blocklists, version constraints, numeric thresholds — values an operator may need to tune per environment. + +**Rule — `input` vs hardcoded `locals`:** +- Use `input {}` **only** for operator-tunable values: allowlists, blocklists, thresholds, time windows. If a value varies per environment or policy set, it belongs in `input`. +- Use `locals` or inline literals for invariant enforcement constants — values that are part of the policy logic itself and should NOT be overridden (e.g. a fixed list of well-known dangerous ports defined by a security standard, a required protocol name). +- Do NOT promote fixed enforcement constants to `input` unless the requirement explicitly says they are configurable. + +### `core::` Functions — Common Idioms + +- **Null safety:** `core::try(attrs.field, default)` — single layer; don't nest. **🔴 MANDATORY: always use the two-step pattern below when the attribute may be explicitly `null`.** +- **Membership:** `core::contains(list, value)` — for lists. For string substring use `core::contains_substring`. +- **Strings:** `core::startswith`, `core::endswith`, `core::contains_substring`, `core::regex` (throws on no match — wrap in `core::try`), `core::split(separator, string)` (use with `core::parseint()` for numeric decomposition — see `verified-syntax.md` Section 2 for full examples). ❌ **Never use `+` for string concatenation** — `+` is numeric addition only; using it with strings throws `Error: Unsuitable value for left operand: a number is required`. ✅ Use `"${local.var}"` string interpolation instead: e.g. `"table/${local.table_name}"` not `"table/" + local.table_name`. +- **Aggregates:** `core::length(list_or_map)`.❌ `core::alltrue()` and `core::anytrue()` **DO NOT EXIST** in tfpolicy runtime — use `core::length()` with list comprehension: `core::length([for b in list : b if b]) > 0` instead of `anytrue`, `core::length([for b in list : b if !b]) == 0` instead of `alltrue`. +- **Ranges:** `core::range(limit)` → `[0, 1, …, limit-1]`; `core::range(lower, upper)` → `[lower, lower+1, …, upper-1]`; `core::range(lower, upper, step)` → step-incremented list from `lower` up to (but not including) `upper`. Works with hardcoded integer literals. ⚠️ With dynamic `attrs.*` integer values (e.g. `attrs.from_port`, `attrs.to_port`) `core::range()` silently returns an empty list in the policytest framework — prefer the count approach for port-range policies (see `verified-syntax.md` Mistake 23). +- **Time:** `core::timestamp()`, `core::formatdate("EEEE", core::timestamp())` (weekday, UTC), `core::parseint(core::formatdate("HH", core::timestamp()), 10)` (hour, UTC). +- **Semver:** `core::semverconstraint(version, "~> 4.67.0")` — supports `=`, `>=`, `<`, `~>`, range, `!=`. Always wrap in `core::try(..., false)` to handle non-semver or unparseable version strings gracefully. ⚠️ Prefer this over `core::split` + `core::parseint` for all version range checks converted from Sentinel string comparisons — manual integer parsing fails silently for non-numeric version suffixes and null/empty inputs. + + **🔴 NEVER place `core::semverconstraint` directly in a `filter =` expression.** Even with `!= null` and `!= ""` guards in the same expression, `semverconstraint` is evaluated regardless of short-circuit ordering in the `filter` context and throws a parse error when the version string is malformed (e.g. a non-semver string like `"x.y"`) or `null`/empty. Always move it into `locals` and wrap with `core::try(..., false)`: + + ```hcl + # ❌ Wrong — crashes when version string is non-semver, null, or empty: + filter = core::try(attrs.version, null) != null && + core::try(attrs.version, "") != "" && + core::semverconstraint(core::try(attrs.version, "0.0"), "< 2.0") + + # ✅ Correct — filter guards null/empty only; semverconstraint lives in locals: + filter = core::try(attrs.version, null) != null && + core::try(attrs.version, "") != "" + locals { + version = core::try(attrs.version, "") + # core::try wraps semverconstraint to safely handle non-semver strings → false + is_old = core::try(core::semverconstraint(local.version, "< 2.0"), false) + # When version is old, enforce the required attribute; when version >= 2.0, always compliant + is_compliant = !local.is_old || local.required_attr_set + } + ``` +- **Null safety — two-step pattern (MANDATORY for any attribute that may be explicitly `null`):** `core::try(attrs.field, default)` triggers the fallback **only when the attribute access throws an error** (key absent). When an attribute is **explicitly set to `null`**, `core::try` returns `null` — not the default. **This is a silent pitfall:** `core::length(null)` crashes with `Invalid value for "collection" parameter`; `null == false` evaluates as `null` (not `true`), causing enforce to trigger unexpectedly. + + Always use the explicit two-step pattern: + + ```hcl + # Collection attribute (list/map) — safe pattern: + field_raw = core::try(attrs.field, null) + field = local.field_raw != null ? local.field_raw : [] + # ✅ Safe to pass to: core::length(), core::contains(), for expressions + + # Scalar boolean attribute — safe pattern: + flag_raw = core::try(attrs.flag, null) + flag = local.flag_raw == null ? false : local.flag_raw + # ✅ Safe to use in: condition = !local.flag, condition = local.flag == false + ``` + + For `filter` expressions that must exclude both `null` and empty-string values: `filter = core::try(attrs.field, null) != null && core::try(attrs.field, "") != ""`. + The two-step rule applies at every nesting level. When a scalar is accessed through a nested path (e.g. `core::try(local.list[0].scalar_attr, default)`), apply the same pattern: if the attribute may be `null`, use `core::try(..., null)` and normalize explicitly. +- **JSON:** `core::jsondecode(string)` — parses a JSON string into an object/list. `core::jsonencode(value)` — encodes a value as a JSON string. ❌ `json::unmarshal` does not exist — use `core::jsondecode` instead. +- **Nested block attribute schema — object vs list:** Terraform provider schemas define nested blocks as either a **list of objects** (`[{ ... }]`) or a single **object** (`{ ... }`). Always check the provider schema before accessing nested attributes: + - **List block** (e.g. `encryption_config = [{ provider = [{ key_arn = "..." }] }]`): access via index `attrs.encryption_config[0].provider[0].key_arn`. Use `core::try(attrs.field, [])` and `field[0].subattr`. + - **Object block** (e.g. `redirect = { port = "443", protocol = "HTTPS" }`): access directly `attrs.redirect.port`. Use `core::try(attrs.redirect.port, "")`. + - ❌ **Never call `core::length()` on an object** — `core::length` requires a list, map, or tuple. Calling `core::length(attrs.redirect)` when `redirect` is an object crashes with `collection must be a list, a map or a tuple`. To check presence of an object block, use `core::try(attrs.redirect, null) != null` instead. + - ❌ **Never iterate over an object block with a `for` expression.** `for r in core::try(attrs.block, [])` — when `attrs.block` is an object, this iterates over the object's **scalar values**, not the object as an element. `core::try(r.sub_attr, "")` on a string silently returns `""`. Use direct attribute access instead: `core::try(attrs.block.sub_attr, "")`. + - When Sentinel mocks use `redirect = { ... }` (object), the TFPolicy test mock and policy must treat it as an object. When Sentinel mocks use `redirect = [{ ... }]` (list), use list indexing. Mismatching the shape causes either runtime crashes or silent wrong results. + +### IAM Policy Checks + +When enforcing IAM content rules (e.g. "no admin `*:*` allowed", "no wildcard actions"), you MUST cover **all 4 inline policy resource types** — not just `aws_iam_policy`. + +**🔴 Always write 4 `resource_policy` blocks for IAM content enforcement:** + +| Resource type | When it's used | `policy` attribute | +|---|---|---| +| `aws_iam_policy` | Standalone managed policy | JSON string — `core::jsondecode(core::try(attrs.policy, "{}"))` | +| `aws_iam_role_policy` | Inline policy attached to a role | JSON string — same pattern | +| `aws_iam_user_policy` | Inline policy attached to a user | JSON string — same pattern | +| `aws_iam_group_policy` | Inline policy attached to a group | JSON string — same pattern | + +**Rule:** A policy that only checks `aws_iam_policy` misses inline policies on roles/users/groups. An admin could bypass it by using `aws_iam_role_policy` instead of `aws_iam_policy`. + +**Note on `aws_iam_policy_document`:** This resource type requires careful distinction between two different use cases: + +- **As a Terraform `data` block** (the common case in real Terraform configurations) — `data_policy` does not exist in tfpolicy (Mistake 31). Do NOT write `data_policy "aws_iam_policy_document"`. In this scenario, the policy document content is consumed by one of the 4 inline/managed policy resource types above, and enforcement should target those resource types. +- **As a `resource` in `.policytest.hcl` mocks and when the Sentinel source reads it via `tfstate/v2`** — `resource_policy "aws_iam_policy_document"` is valid and directly targets the document's `statement` attribute (lowercase `actions`, not `Action`). When a Sentinel policy reads `aws_iam_policy_document` from `tfstate`, the correct TFPolicy conversion is `resource_policy "aws_iam_policy_document"` — do NOT substitute inline/managed policy resource types. + +**Decision rule:** Check the Sentinel `import` statement. If the Sentinel uses `tfstate/v2` to read `aws_iam_policy_document` resources, convert to `resource_policy "aws_iam_policy_document"`. If the Sentinel reads the consuming resource (`aws_iam_role_policy`, etc.), target those 4 types instead. + +```hcl +# ✅ CORRECT — define the check logic once, repeat for all 4 resource types +# (Each resource_policy block is independent; locals are block-scoped) + +resource_policy "aws_iam_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_role_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM role inline policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_user_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM user inline policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_group_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM group inline policies must not grant full admin privileges (*:* on *)." + } +} +``` + +### Cross-Resource Lookups + +**🛑 Self-check — run this BEFORE writing any `resource_policy` involving a companion/child resource:** + +> "What is the enforcement goal?" +> - **"Every existing instance of `` must have correct attributes"** (e.g., "every `aws_lb_listener` must use HTTPS") → anchor `resource_policy` directly on the child type +> - **"Every parent must have at least one compliant child"** (e.g., "every S3 bucket must have a `public_access_block` with all four flags set") → anchor `resource_policy` on the **parent**; a child-only policy silently misses parents with no child in the plan + +**Decision table — choose the correct pattern first:** + +| Enforcement goal | Who gets the `resource_policy`? | Pattern | +|---|---|---| +| "Every existing instance of `` must have correct attributes" (e.g., "every `aws_lb_listener` must use HTTPS") | Child type directly | Direct child `resource_policy` | +| "Every parent must have at least one compliant child" (e.g., "every S3 bucket must have a `public_access_block` with all four flags set") | **Parent** resource | Apply-time: inline `core::getresources("child", {bucket = attrs.id})` inside `resource_policy` | +| "Parent has a companion identified by a known literal / stable attribute" (e.g., versioning bucket name matches `attrs.bucket`) | Parent resource | Plan-time: top-level `core::getresources("companion", {})` + HCL for-loop filter inside `resource_policy` | + +**⚠️ Critical:** Never write a `resource_policy "child_type"` to enforce "every parent must have a child" — if the child resource is absent entirely, the policy never fires for that parent and the violation is silently missed. + +**🔴 Named companion resources — ALWAYS use the parent as `resource_policy` anchor when the goal is presence enforcement:** + +The following Terraform resource types are **companion-only** — a parent resource can exist in a plan without them. NEVER anchor a standalone `resource_policy` on these types when checking for their presence: + +| ❌ Never use as standalone `resource_policy` anchor (for presence) | ✅ Always anchor on | Lookup key | +|---|---|---| +| `aws_s3_bucket_public_access_block` | `aws_s3_bucket` | `{ bucket = attrs.id }` (apply-time inline) | +| `aws_s3_bucket_acl` | `aws_s3_bucket` | `{ bucket = attrs.id }` (apply-time inline) | +| `aws_s3_bucket_logging` | `aws_s3_bucket` | `{ bucket = attrs.id }` (apply-time inline) | +| `aws_s3_bucket_server_side_encryption_configuration` | `aws_s3_bucket` | `{ bucket = attrs.id }` (apply-time inline) | +| `aws_s3_bucket_versioning` | `aws_s3_bucket` | plan-time top-level + `v.bucket == attrs.bucket` filter | +| `aws_s3_bucket_policy` | `aws_s3_bucket` | `{ bucket = attrs.id }` (apply-time inline) — even for content enforcement (e.g., checking `Principal: "*"`), inspect the child's `policy` attr via `core::jsondecode()` inside the parent block | + +> This pattern applies to all resource families, not just S3. Non-S3 examples: when checking "every VPC has a flow log" → anchor on `aws_vpc` with inline `core::getresources("aws_flow_log", {vpc_id = attrs.id})`; when checking "every LB has at least one compliant listener" → anchor on `aws_lb` with inline `core::getresources("aws_lb_listener", {load_balancer_arn = attrs.arn})`. + +> **Why this matters:** Even if the Sentinel source iterates over the companion type, TFPolicy must anchor on the parent. A Terraform plan can declare an `aws_s3_bucket` with NO companion resource — anchoring on the companion silently misses that bucket entirely. + +**⚠️ Parent-anchor means parent-ONLY:** Once the decision table says "parent resource" is the anchor, generate `resource_policy` blocks **exclusively** on the parent type. Do NOT also generate standalone `resource_policy` blocks for companion types alongside the parent blocks. Adding companion blocks in parallel: +- still silently misses parents that have no companion +- double-reports violations + +All enforcement logic — including every companion check — must live inside the parent `resource_policy` block, using inline or top-level lookups. + +--- + +**Plan-time pattern** — use `core::getresources()` at the **top level** only when the filter value is a **known constant** (not derived from `attrs.*`). When the filter depends on the current resource's own attribute, use the inline pattern below instead. ❌ Top-level `{}` (empty filter) + for-loop or lookup map filtered by `attrs.*` inside `resource_policy` is the prohibited anti-pattern — see verified-syntax.md Mistake 13 for both variants. + +**⚠️ Exception — "companion absence is itself a violation":** When the goal is to enforce that a companion resource **exists AND has matching attributes** (e.g. a parent resource must have a linked companion with a specific qualifying attribute set), the inline filter pattern produces false negatives: if the companion is absent or has a mismatching linking key, `core::getresources()` returns `[]`, and `!has_companion` is `true` → a condition like `!has_companion || check_attr == "VALUE"` incorrectly passes. Use the **top-level collect-then-filter pattern** instead: + +```hcl +# Top-level: collect ALL companions globally, filter to those with the qualifying attribute, +# then check membership inside resource_policy. +locals { + all_companions = core::getresources("aws_companion_resource", {}) + # Filter to companions that have the qualifying attribute set (e.g. non-empty elb) + qualifying_companions = [for c in local.all_companions : c + if core::try(c.qualifying_attr, null) != null && core::try(c.qualifying_attr, "") != ""] + # Collect the linking attribute values from qualifying companions only + companion_names = [for c in local.qualifying_companions : core::try(c.linking_attr, "")] + # Use qualifying companions (not all) to decide whether to evaluate + has_qualifying = core::length(local.qualifying_companions) > 0 +} + +resource_policy "aws_parent_resource" "example" { + # Only evaluate when qualifying companions exist in the plan + filter = local.has_qualifying + + locals { + parent_name = core::try(attrs.name, "") + is_linked = core::length([for n in local.companion_names : n if n == local.parent_name]) > 0 + # 🔴 Condition must be POSITIVE: both linked AND check_attr correct + # Do NOT use: !is_linked || check_attr == "EXPECTED" ← this passes when companion absent + is_compliant = local.is_linked && core::try(attrs.check_attr, "") == "EXPECTED" + } + + enforce { + condition = local.is_compliant + error_message = "..." + } +} +``` + +This pattern correctly detects: +- Companion absent entirely → `has_qualifying = false` → `filter = false` → parent skipped ✓ +- Companion present but with no qualifying attribute → `qualifying_companions = []` → `has_qualifying = false` → parent skipped ✓ +- Companion with wrong `linking_attr` value → `is_linked = false` → `is_compliant = false` → violation ✓ +- Companion with correct linking and correct `check_attr` → `is_compliant = true` → passes ✓ + +**⚠️ Choose `filter` scope carefully:** `filter = local.has_qualifying` (qualifying companions only) skips all parents when no qualifying companions exist — this matches a Sentinel that returns early when no qualifying companions are found. If the Sentinel does NOT skip when non-qualifying companions exist (e.g. it evaluates parents even when only ALB/NLB attachments are present), use `filter = core::length(local.all_companions) > 0` instead. With this broader filter, unlinked parents (`is_linked = false`) are evaluated and correctly fail `is_linked && check_attr == VALUE`. + +**🔴 Condition polarity rule:** When using the top-level collect-then-filter pattern, always use a **positive** condition (`is_linked && check_attr == VALUE`), not a negative condition (`!is_linked || check_attr == VALUE`). The negative form silently passes any parent that is not linked — which is the opposite of the intended enforcement. With the positive form: unlinked parent → `is_linked = false` → `is_compliant = false` → violation (correct). With the negative form: unlinked parent → `!false || any` = `true` → `is_compliant = true` → passes (incorrect). + +**Apply-time pattern** — when the filter value is the current resource's own attribute (e.g. `bucket = attrs.id`, or `event_bus_name = attrs.name`), use an **inline `core::getresources()` call with the direct filter inside `resource_policy`**. The filter cannot resolve at plan time (the attribute value is unknown until apply), but fully resolves once the resource is provisioned. The linking attribute may reference `attrs.id`, `attrs.arn`, or `attrs.name` — check the child resource's Terraform Registry docs to determine which one. + +> **S3 cross-resource note:** Always use `attrs.id` (not `attrs.bucket`) when filtering S3 child resources such as `aws_s3_bucket_public_access_block`, `aws_s3_bucket_acl`, `aws_s3_bucket_server_side_encryption_configuration`, and `aws_s3_bucket_policy`. Terraform providers set the child resource's linking attribute (`bucket`) to the parent bucket's `.id`. Using `attrs.id` ensures the filter matches the actual value stored in the child resource's plan. + +```hcl +# NOTE: This policy contains a cross-resource reference that will not resolve during plan time, +# but the policy will run successfully during apply time. +resource_policy "aws_s3_bucket" "s3_block_public_access" { + locals { + public_access_block = core::getresources("aws_s3_bucket_public_access_block", { + bucket = attrs.id + }) + block_public_acls = core::try(local.public_access_block[0].block_public_acls, false) + block_public_policy = core::try(local.public_access_block[0].block_public_policy, false) + ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false) + restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false) + } + + enforce { + condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets + error_message = "S3 bucket does not have all public access block settings enabled." + } +} +``` + +### Error Message Rules + +- ✅ Static strings: `"S3 buckets must enable versioning."` +- ✅ Safe interpolation: `"Instance type '${attrs.instance_type}' is not allowed."` +- ❌ **Never** interpolate `${meta.address}` — it is UNDEFINED in `resource_policy` and crashes at runtime. `tfpolicy test` will NOT catch this; only a real `terraform plan` will. +- Use `error_message` for all enforceable violations (condition can be false). +- Use `info_message` **only** in non-convertible stub blocks where `condition = true` and no real enforcement is possible. Never use `info_message` in a block that can actually fail a resource. + +### Comment Conventions — `# LIMITATION:` vs `# NOTE:` + +These two markers have distinct meanings — do not interchange them: + +- **`# LIMITATION:`** — tfpolicy **cannot fully express or enforce** the requirement. Part of the original Sentinel logic had to be omitted or approximated. Always accompanies a `Simplify` or `Not convertible` quality label. + - Example: *"LIMITATION: The bucket policy IAM document check is non-convertible — tfpolicy does not expose reference metadata."* +- **`# NOTE:`** — The enforcement is complete but has a **runtime caveat** that does not reduce coverage. + - Example: *"NOTE: This policy contains a cross-resource reference that will not resolve during plan time, but will run successfully during apply time."* + +### Output Structure Rules — Consistency-Critical + +These rules eliminate the most common sources of non-deterministic output between runs: + +**Rule 1 — One `resource_policy` block per resource type.** +Multiple checks on the same resource type MUST be combined into a single `resource_policy` block using separate `locals` and multiple `enforce` blocks. Never split checks into two `resource_policy` blocks for the same type. + +```hcl +# ✅ Correct — two checks, one block +resource_policy "aws_ecs_task_definition" "secure_networking" { + filter = core::try(attrs.network_mode, "") == "host" + locals { + containers = core::try(core::jsondecode(attrs.container_definitions), []) + non_privileged = [for c in local.containers : c if core::try(c.privileged, false) != true] + insecure_user = [for c in local.containers : c if core::try(c.user, "") == "" || core::try(c.user, "") == "root"] + } + enforcement_level = "advisory" + enforce { + condition = core::length(local.non_privileged) == core::length(local.containers) + error_message = "ECS task definition containers must not run as privileged." + } + enforce { + condition = core::length(local.insecure_user) == 0 + error_message = "ECS task definition containers must define a non-root user." + } +} + +# ❌ Wrong — same resource type split across two blocks +resource_policy "aws_ecs_task_definition" "check_privileged" { ... } +resource_policy "aws_ecs_task_definition" "check_user" { ... } +``` + +**Rule 2 — Non-convertible checks are comments, not stub blocks.** +When a specific check cannot be converted (reference metadata, etc.), document it as a `# LIMITATION:` comment inside the existing `resource_policy` block — do NOT create a separate `resource_policy` block of the same type as a stub. A dedicated stub block (with `condition = true`) is only appropriate when the **entire** policy has no convertible checks at all. + +**Rule 3 — Per-resource enforcement for all checks.** +Do NOT aggregate across all resources of a type at the plan level (e.g. "at least one trail in the whole plan is compliant = pass all"). Each `resource_policy` must evaluate each individual resource independently. tfpolicy's evaluation model is per-resource — plan-level aggregation via top-level `core::getresources()` to pass/fail based on a count across all resources is not idiomatic and produces non-deterministic results. + +```hcl +# ✅ Correct — each aws_cloudtrail resource evaluated independently +resource_policy "aws_cloudtrail" "s3_dataevents_enabled" { + locals { ... } + enforce { + condition = local.is_compliant + error_message = "This CloudTrail trail must log S3 data events." + } +} + +# ❌ Wrong — plan-level aggregation ("at least one compliant trail") +locals { + all_trails = core::getresources("aws_cloudtrail", {}) + num_compliant = core::length([for t in local.all_trails : t if ...]) + any_compliant = local.num_compliant > 0 +} +resource_policy "aws_cloudtrail" "s3_dataevents_enabled" { + enforce { + condition = local.any_compliant # Wrong: passes every trail if any one trail is compliant + error_message = "..." + } +} +``` + +--- + +## Sentinel → Terraform Policy Conversion + +Use this section when the input is an existing Sentinel `.sentinel` file. Follow Steps 1–5 below; apply the authoring guidance above when generating the policy HCL. + +### Sentinel → Terraform Policy construct mapping + +| Sentinel | Terraform Policy | +|----------|-----------------| +| `import "tfplan/v2"` | Native policy context — no import needed | +| `tfplan.resource_changes` loops | Usually one `resource_policy` per resource type; split multi-type Sentinel rules when needed | +| `filter tfplan.resource_changes` | Resource type in the policy declaration plus optional `filter` for attribute-based preconditions | +| `as address, rc` | `attrs.*` and `meta.provider_type` for the current resource. **⚠️ `meta.address` is UNDEFINED — do not use it.** | +| `rc.change.after.` | `attrs.` | +| `rc.change.before.` | `prior_attrs.` — available when `operations` does NOT include `"create"` | +| `rc.change.actions is ["delete"]` | `operations = ["delete"]` — fires only on destroy | +| `rc.change.actions is not ["delete"]` | `operations = ["create", "update"]` — skips destroy | +| `param allowed_list default [...]` | `input "allowed_list" { type = list(string); default = [...] }` | +| `time.now.weekday_name` | `core::formatdate("EEEE", core::timestamp())` — UTC weekday name | +| `time.now.hour` | `core::parseint(core::formatdate("HH", core::timestamp()), 10)` — UTC hour as int | +| `strings.has_prefix(s, p)` | `core::startswith(s, p)` — arg order: **full string first, prefix second** (same as Sentinel). **Note:** `meta.version` in `provider_policy` is the **resolved version** (e.g. `"6.50.0"`), NOT the constraint string. Sentinel's `strings.has_prefix(p.version_constraint, ">")` is **non-convertible** — tfpolicy does not expose the constraint string. Use `core::semverconstraint(meta.version, ...)` instead. | +| `strings.has_suffix(s, suffix)` | `core::endswith(s, suffix)` | +| `rc.provider_name` | `meta.provider_type` | +| `all/any expressions` | List comprehensions with filtered counts — **neither `core::alltrue()` nor `core::anytrue()` exist**. Use `core::length([for x in list : x if !x]) == 0` for "all true" and `core::length([for x in list : x if x]) > 0` for "any true". | +| `else` clause | Multiple `enforce` blocks | +| `maps.get(obj, key, default)` | `core::try(obj.key, default)` | +| `collection.reject(items, predicate)` | List comprehension with `if` — `[for item in items : item if !]` | +| `collection.reject(items, predicate) is empty` | **🔴 ALL condition** — every element satisfies the predicate. ⚠️ **Do NOT convert this to an ANY condition** (`length([for item in items : item if ]) > 0`) — that inverts the semantics. The correct pattern is: `non_compliant = [for item in items : item if !]` / `is_compliant = core::length(local.non_compliant) == 0`. Example: `collection.reject(log_opts, func(o) { o.enabled and o.log_type is "AUDIT_LOGS" }) is empty` → `non_compliant = [for o in local.log_opts : o if !(core::try(o.enabled, false) && core::try(o.log_type,"") == "AUDIT_LOGS")]` / `condition = core::length(local.log_opts) > 0 && core::length(local.non_compliant) == 0`. When the predicate tests a boolean flag, verify the Sentinel default value and match it in `core::try(attr, )`. | +| `collection.filter(items, predicate)` | List comprehension with `if` — `[for item in items : item if ]` | +| `strings.split(sep, str)` | `core::split(separator, string)` — splits a string into a list of substrings at each occurrence of `separator`. Example: `core::split("-", "80-443")` → `["80","443"]`. ⚠️ For **version range checks**, prefer `core::semverconstraint()` over `core::split` + `core::parseint` — see the Semver note in the `core::` Functions section. | + +> When converting Sentinel `summary {}` output or `print()` statements, **do not** reproduce address-listing behavior. Terraform Policy diagnostics already identify the failing resource — prefer remediation-focused messages instead. + +### Sentinel features that ARE convertible + +1. **Time-based rules** — `core::timestamp()` + `core::formatdate()` + `core::parseint()` cover Sentinel's `time` import. All values are UTC; document that assumption in policy comments. +2. **`param` blocks** — direct equivalent: `input` blocks with `type` and `default`. +3. **`rc.change.before` for update/delete** — `prior_attrs` is available when `operations` does NOT include `"create"`. +4. **Integer range checks** — Sentinel policies that check whether all ports within `[from_port, to_port]` are authorized CAN be converted. Use the count approach: filter `authorized_ports` to those within the range and compare the count to `to_port - from_port + 1`. Do not use `core::range()` with dynamic `attrs.*` values. See `verified-syntax.md` Mistake 23. +5. **`tfconfig/v2` reference count** — each resource reference is stored **twice** in `.references` (once as `resource.name`, once as `resource.name.id`). When simplifying a reference-count check to a direct `core::length(attrs.attribute)` check, **halve the threshold**: `references > 2` → `core::length(attrs.attribute) >= 2`. + +### Plan-Time vs Apply-Time Policies + +Terraform Policy can enforce controls at **plan time** (before `terraform apply`) or **apply time** (during `terraform apply`). Most policies are plan-time, but cross-resource lookups that depend on newly-created resource IDs only fully resolve at apply time. + +| Scenario | Enforcement time | Quality label | +|----------|-----------------|---------------| +| Single-resource attribute checks | Plan time | Perfect / Good | +| Cross-resource lookup where the filter value is a **known literal or existing resource ID** | Plan time | Good | +| Cross-resource lookup where the filter value is a **newly-created resource ID** (e.g. `bucket = attrs.id` for a bucket created in the same plan) | Apply time | Good | +| Reference metadata / graph traversal (`res.config.attribute["references"]`) | Not convertible | — | + +**When a Sentinel policy uses cross-resource references with a value-based filter from a newly-created resource:** +- Generate the policy using an **inline `core::getresources()` call with the direct filter** inside `resource_policy`. +- Add this note in the conversion report (not a limitation label): *"This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."* +- Do **not** label this as Simplify or Not convertible — it is a valid **Good** conversion. + +**For cross-resource patterns, apply the registry check (Steps A and B) described in "Cross-Resource Lookups" above.** The registry check fully determines the policy structure, regardless of whether the Sentinel's `violations` iterated the parent or the child type. Dependent child resource types must never have a standalone `resource_policy` block. + +### Cannot Convert (Explain the Alternative) + +1. **Mocking/testing infrastructure** (`import "tfconfig-functions"`) — tfpolicy uses `.policytest.hcl`. See the [tfpolicy-test skill](tfpolicy-test.md). +2. **Custom Sentinel imports** — limited plugin support; use HTTP plugins or native functions if available. +3. **Sentinel simulator / built-in test framework** — replace with `.policytest.hcl` test files. +4. **Cross-workspace data access** — tfpolicy evaluates a single plan. Use workspace tags (`meta.tfe_workspace.tags`) or external plugins. +5. **`print()` / debug statements** — no debug output mechanism; rely on concise `error_message` / `info_message` text only when it adds remediation context. +6. **Stateful logic across evaluations** — policies are stateless; use external systems via plugins if state is required. +7. **`rc.change.before` outside delete/update** — for first-time creates there is no pre-state. +8. **Cross-resource reference navigation via reference metadata** (`res.config.attribute["references"]`, `res.config.to`) — tfpolicy does not expose which Terraform resource a value *points to*. When the Sentinel policy uses the *resolved value* of an attribute (not the reference path itself), convert using `core::getresources()` with a value-based filter — see "Plan-Time vs Apply-Time Policies" above. +9. **Data source content inspection by address** — `core::getdatasource()` requires filter attributes and cannot query by Terraform address. +10. **Complex resource-graph traversal via reference metadata** — cannot traverse the Terraform resource graph by reference (e.g. "find all subnets that reference this VPC"). Only resolved attribute values are available. If the Sentinel policy traverses by *resolved attribute value* (e.g. `bucket = attrs.id`), convert using `core::getresources()` with a value-based filter and mark as an apply-time policy — see "Plan-Time vs Apply-Time Policies" above. + +> ⚠️ **Partial reference dependence — do not skip the whole policy.** Items 8 and 10 apply to the *specific check* that uses reference metadata, not the entire policy. If only some checks in a Sentinel policy rely on `res.config.attribute["references"]` or graph traversal, convert the remaining checks as normal, apply the **Simplify** label to the overall policy, and document each skipped check in the report with: *"This check was omitted — tfpolicy does not expose reference metadata (`res.config.attribute["references"]`)."* Only label the entire policy as **Not convertible** if its core enforcement logic is wholly dependent on reference metadata with no convertible remainder. + +> ❌ **Do not approximate reference-metadata checks with cross-resource JSON value matching.** A common workaround is to retrieve all instances of a related resource via `core::getresources()` and compare their serialized attribute values (e.g. `attrs.policy == doc.json`) as a proxy for "this resource references that data source." This is **not a faithful conversion** — it produces false negatives when the referenced resource is already deployed and absent from the current plan, and enforces a different semantic (value equality) than the original (structural reference). When the only check IS reference metadata, generate a stub policy instead: +> ```hcl +> # — Non-Convertible (Reference Metadata) +> resource_policy "" "_stub" { +> enforce { +> condition = true +> info_message = "Automated enforcement not available: this policy requires reference metadata inspection which tfpolicy does not support. Manual compliance review required." +> } +> } +> ``` + +> ⚠️ **Partial exception — direct content inspection via the parent bucket.** The cross-resource approximation prohibition applies to comparing JSON *across* resources (e.g. fetching all `aws_iam_policy_document` outputs and matching against a bucket policy). It does **not** prohibit inspecting a bucket's own policy content. When the Sentinel reference-metadata check is really enforcing *content* (e.g. "the bucket policy must not grant public read access"), convert it by anchoring on `resource_policy "aws_s3_bucket"`, fetching the child `aws_s3_bucket_policy` via `core::getresources("aws_s3_bucket_policy", { bucket = attrs.id })`, and inspecting its `policy` attribute via `core::jsondecode()` inside the parent block. This follows the standard dependent-child pattern — `aws_s3_bucket_policy` always requires a parent bucket. Label the overall policy **Simplify** (because the reference-path check is omitted) and add a `# NOTE:` that the content-based check achieves a similar security outcome. +> +> ```hcl +> # ✅ Content-based alternative to reference-metadata check on bucket policies +> # NOTE: This policy contains a cross-resource reference that will not resolve during plan time, +> # but the policy will run successfully during apply time. +> resource_policy "aws_s3_bucket" "no_public_read_policy" { +> locals { +> bucket_policy = core::getresources("aws_s3_bucket_policy", { bucket = attrs.id }) +> policy_doc = core::try(core::jsondecode(core::try(local.bucket_policy[0].policy, "{}")), { Statement = [] }) +> statements_enriched = [for s in core::try(local.policy_doc.Statement, []) : { effect = core::lower(core::try(s.Effect, "Allow")), action = core::try(s.Action, []), principal_str = core::try(s.Principal, ""), principal_aws = core::try(core::try(s.Principal, {}).AWS, "") }] +> public_read_stmts = [for s in local.statements_enriched : s if s.effect == "allow" && (core::contains(s.action, "s3:GetObject") || core::contains(s.action, "s3:*") || core::contains(s.action, "*")) && (s.principal_str == "*" || s.principal_aws == "*")] +> } +> enforce { +> condition = core::length(local.public_read_stmts) == 0 +> error_message = "S3 bucket policy must not grant public read access (Principal: * with s3:GetObject or s3:*)." +> } +> } +> ``` + +### Conversion quality labels + +- **Perfect** — Same enforcement intent and behavior expressed directly in tfpolicy with no known semantic gap. +- **Good** — Preserves the important enforcement outcome using idiomatic tfpolicy structure (not a one-to-one translation). +- **Simplify** — Only part of the original Sentinel behavior can be reproduced; document the missing checks explicitly. +- **Not convertible** — tfpolicy lacks the runtime data or language features required for a safe translation. + + +### Conversion Strategy + +| Tier | Pattern | Examples | Approach | +| --- | --- | --- | --- | +| ✅ **Easy** | Single resource, direct attribute check | EBS encryption, RDS public access, ECS container insights, EKS audit logging, Lambda runtime, CloudTrail logging | Direct 1:1 conversion | +| ✅ **Good** | Cross-resource value-based lookup (resolved attribute IDs) | S3 + public-access-block, S3 + versioning, EventBridge + resource policy | Use inline `core::getresources()` with direct filter inside `resource_policy`; mark as apply-time policy if filter value is a newly-created resource ID | +| ⚠️ **Simplify** | Cross-resource logic with partial reference dependence | EC2 IMDSv2, security group coverage | Check explicit configuration only; document what is not checked; prefer known literal attributes over inferred relationships | +| ❌ **Avoid** | `rc.change.before` outside update/delete; reference-metadata navigation; data-source content inspection by address; complex graph traversal; mutable external state; `strings.split()` decomposition | — | Recommend a redesign or treat as non-convertible | + +### Steps to Convert a Sentinel Policy + +#### Step 1 — Parse the Sentinel Structure +Identify imports (tfplan, tfconfig, tfstate, custom), filter logic and resource selection, main and sub-rules, and enforcement level (advisory vs mandatory). Ask: What resources are being checked? Which attributes are validated? Does the policy depend on before/after diff? Are there cross-resource dependencies? Are data sources inspected? Does it rely on reference metadata or graph traversal? + +**Identify the enforcement target and the parent type:** For any policy involving cross-resource dependencies, first perform the registry check (Steps A and B in "Cross-Resource Lookups" above) to identify which resource types are dependent children and which are parent types. Then use the Sentinel's `violations` expression to confirm the parent type — this is the type your `resource_policy` targets. Write `resource_policy` on the parent type. Every dependent child type is accessed exclusively via `core::getresources()` inside that parent block; all conditions on child resources are evaluated there, and all violations are reported on the parent. Do **not** write a standalone `resource_policy` on any resource type that the registry identifies as a dependent child — this rule holds regardless of whether the Sentinel's `violations` iterated the child or the parent, and regardless of whether the check is about a missing child or a misconfigured one. + +> **Cross-resource policies — use the Terraform Registry to identify the parent, linking attribute, and filter value.** For any child resource type involved in a cross-resource lookup, fetch its documentation at `https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown`. Look for a `(Required)` argument referencing a parent resource in "Argument Reference" and confirm with the usage examples (`.id` vs `.arn`). The required argument name is the linking attribute; use the corresponding `attrs.id` or `attrs.arn` in the `core::getresources()` filter. This applies to all resource families — not just S3. + +**Do not over-constrain the enforcement condition beyond the Sentinel intent.** When the Sentinel checks "at least one item in a collection satisfies condition X", the correct tfpolicy translation is `core::length([for item in local.items : item if ]) > 0`. Do NOT translate this to "every item must satisfy X" (i.e. `core::length([for item in local.items : item if !]) == 0`) unless the Sentinel explicitly enforces ALL items. Over-constraining the condition creates false violations for valid configurations that the Sentinel would pass. + +**Distinguish enforcement intent from Sentinel implementation detail.** Sentinel code often accesses a collection element by index (e.g. `origins[0]`) as a traversal shortcut rather than an intentional "only check the first item" rule. Before encoding an index access as a scope restriction, ask: + +- Is the indexed access repeated for all resources in a filter loop? (If so, it is a loop artifact, not a one-item intent.) +- Does the policy name or comment indicate intent that applies to "all" items? +- Would checking only the first item leave a real security gap? +- Is this collection a **structurally singleton block** in the provider schema (e.g. `max_items = 1`) — in which case `[0]` is intentional? + +When the answer to any of the first three questions is "yes" **and** the fourth is "no", convert the check to iterate **all** items in the collection, not just `[0]`. Document the interpretation in the requirements as: *"Sentinel source accesses `collection[0]`; interpreted as checking all items to preserve full enforcement intent."* + +#### Step 2 — Assess Convertibility +Check for non-convertible patterns above. Document what cannot be converted and assign a quality label. + +#### Step 3 — Map to tfpolicy Constructs +Use the mapping table above. In practice, focus on (in order): matching resource scope, translating attribute access and null handling, replacing collection helpers with list comprehensions, and splitting compound logic into `locals` + multiple `enforce` blocks. + +#### Step 4 — Generate the policy +Follow the authoring guidance in the Knowledge Base sections above. Apply the cross-resource decision table and companion-anchor rules. + +**Error-message rules:** +- ✅ Static strings, or safe `${attrs.fieldname}` interpolation. +- ❌ **Never** interpolate `${meta.address}` — it is UNDEFINED in `resource_policy` and throws `Error: Unsupported attribute` at runtime for every evaluated resource. `tfpolicy test` will not catch this; only a real `terraform plan --policies=` run will. + +#### Step 5 — Document the Conversion +Include the quality label, test success rate (if tests written), any limitations or simplifications made, behavioral differences from Sentinel, and references to related documentation. + +--- + +- [`tfpolicy-author.md`](tfpolicy-author.md) — guided first-policy walkthrough. +- [`tfpolicy-author.md`](tfpolicy-author.md) — reusable patterns (attribute checks, allowlists, cross-resource enforcement, etc.). +- [`verified-syntax.md`](verified-syntax.md) — verified syntax tables, runtime limitations, common-mistake corrections. **Source of truth — defer to this file when this SKILL.md disagrees.** + +## Usage Instructions — Write a New Policy from User Intent + +### Step 1 — Clarify requirements +- Which resources / modules / providers to target. +- The specific condition to enforce. +- Whether create, update, and/or destroy should be in scope (`operations`). +- Whether any value should be tunable per policy set (→ `input` block). +- The desired error message and whether `attrs.*` interpolation is helpful. + +### Step 2 — Design the structure +- Choose the policy type (resource / module / provider). +- Decide whether a wildcard label (`"*"`) is appropriate. +- Plan the `filter` for performance and to exclude resources where the attribute is meaningfully absent. **Two cases:** + - *Attribute absent = resource out of scope* (e.g. no `acl` block set at all → resource doesn't configure ACLs → skip it): use `filter = core::try(attrs.field, null) != null`. + - *Attribute absent = AWS provider default applies* (e.g. `encrypted` absent → AWS defaults to `false` → resource is still in scope and may violate the policy): do **not** filter on null. Use `core::try(attrs.field, )` in the `condition` instead so absent resources are evaluated against the effective default. +- Move complex predicates into `locals` for readability. + +### Step 3 — Generate the policy +- Wrap optional attributes in `core::try()`. +- Keep each boolean expression on a single line. +- Use multiple `enforce` blocks when you want independent diagnostics. +- Never interpolate `${meta.address}` in `error_message`. + +### Step 4 — Document the policy +- Header comment with description, resources checked, and any compliance reference. +- Note operation scope and any parameterization. + +### Worked Example + +User request: *"Ensure all S3 buckets have versioning enabled."* + +```hcl +# Ensure S3 Bucket Versioning is Enabled +# +# Enforces that all AWS S3 buckets have versioning enabled to protect +# against accidental deletion and enable recovery. +# +# Resources checked: +# - aws_s3_bucket with inline versioning configuration +# - aws_s3_bucket_versioning (standalone resource pattern) + +resource_policy "aws_s3_bucket" "versioning_enabled" { + filter = attrs.versioning != null + + locals { + versioning_enabled = core::try(attrs.versioning[0].enabled, false) + } + + enforce { + condition = local.versioning_enabled == true + error_message = "S3 buckets must set versioning.enabled = true to protect against accidental deletion." + } +} + +resource_policy "aws_s3_bucket_versioning" "versioning_enabled" { + locals { + versioning_status = core::try(attrs.versioning_configuration[0].status, "Disabled") + } + + enforce { + condition = local.versioning_status == "Enabled" + error_message = "S3 bucket versioning resources must have status 'Enabled'. Current status: '${local.versioning_status}'." + } +} +``` + +## Best Practices + +### Policy Writing +1. Use descriptive policy names. +2. Add a comprehensive header comment with description, resources checked, and compliance references. +3. Always use `core::try()` for optional attributes. +4. Break down complex logic with `locals`. +5. Provide actionable, remediation-focused error messages. +6. Cover all variations of a resource family (e.g. AWS security groups: `aws_security_group`, `aws_security_group_rule`, `aws_vpc_security_group_ingress_rule`, `aws_default_security_group`). +7. Use `filter` to skip resources that don't apply (saves work and avoids false positives). +8. **Cache `core::getresources()` results in top-level locals** when the filter is a known literal or an existing resource ID — this avoids O(N) overhead per resource. **Exception:** when the filter depends on the current resource's own attribute (e.g. `{bucket = attrs.id}`, `{event_bus_name = attrs.name}`), the call cannot be pre-computed at top level because `attrs` is only available inside `resource_policy` — use the inline pattern instead (see item 15). ❌ Do NOT work around this by fetching all child resources at the top level with `{}` and building a lookup map — that is the same anti-pattern restructured. +9. **Avoid `core::getdatasource()` inside `resource_policy`** — it calls provider APIs. +10. Build lookup maps once for O(1) matching when iterating many resources. +11. Keep each boolean expression on a single line (HCL parser limitation in beta). +12. Use clear variable names (`scanning_config`, not `sc`). +13. Convert sets to lists before indexing: `[for item in set : item][0]`. +14. Don't use `core::try()` defaults to mask missing values that should fail the policy — use `filter` instead. +15. For cross-resource lookups where the filter value is the current resource's own attribute: use an inline `core::getresources()` with the direct filter inside `resource_policy`. To find the correct linking attribute name and filter value, fetch the child resource's Terraform Registry documentation at `https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown` and look for the `(Required)` **or `(Optional)`** argument that references the parent resource. Check whether the usage examples assign it `.id`, `.arn`, or `.name` — use `attrs.id`, `attrs.arn`, or `attrs.name` accordingly. ⚠️ Some linking attributes are `(Optional)` in the schema (e.g. `event_bus_name` on `aws_cloudwatch_event_bus_policy` defaults to the default bus) but still represent a parent-child link — treat them the same way. Always add this comment in the policy: *"This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."* Do not use a top-level cache + for-loop for this pattern. +16. **Cross-resource enforcement — registry check determines the structure unconditionally:** + - **First, verify every resource type via the Terraform Registry.** For any resource type involved in a cross-resource check, fetch `https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown`. A resource is a **dependent child** if it has a `(Required)` argument whose description or usage examples reference another AWS resource by `.id`, `.arn`, or `.name`. The argument name is the **linking attribute**; the assignment in examples tells you whether to use `attrs.id`, `attrs.arn`, or `attrs.name`. The resource that the linking attribute points to is the **parent type**. + - **When the enforcement goal is to ensure every parent has a compliant child, the dependent child must NEVER have a standalone `resource_policy` block.** Write the `resource_policy` block on the **parent type**. Fetch the dependent child inside the parent block via `core::getresources("", { = attrs.id_or_arn_or_name})`. Evaluate all attribute checks on those lookup results. Report all violations on the parent. When the goal is only to check every existing child's own attributes, a standalone `resource_policy` on the child type is valid — see Self-check above. + - Concrete examples: `aws_s3_bucket_public_access_block`, `aws_s3_bucket_policy`, `aws_s3_bucket_acl` (all require `bucket`) → never standalone for any enforcement goal; always fetched inside `resource_policy "aws_s3_bucket"`. `aws_lb_listener` → standalone `resource_policy "aws_lb_listener"` is valid when checking every listener's own attributes (e.g., protocol, ssl_policy); use `resource_policy "aws_lb"` with inline lookup only when the goal is "every LB must have at least one compliant listener". + - Always add this comment when using this pattern: *"This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."* + +### Communication +1. Ask clarifying questions; don't assume requirements. +2. Show sample passing and failing resources alongside the policy. +3. Explain enforcement-level trade-offs. +4. Offer simplifications when an exact rule isn't expressible. + +## See Also +- [`tfpolicy-test`](tfpolicy-test.md) — write `.policytest.hcl` files to validate the policies authored here. +- [`../../examples/README.md`](../examples/README.md) — side-by-side Sentinel + `.policy.hcl` examples with quality labels and per-example READMEs. +- [`verified-syntax.md`](verified-syntax.md) — shared source-of-truth syntax reference. + +--- + +**Purpose:** Quick reference for AI agents to start writing Terraform Policy (tfpolicy) +**Status:** All behaviors verified during private beta (2026-02-19) +**Compatible with:** Any AI system capable of reading markdown and generating HCL code + +--- + +## Quick Start for AI Agents + +When a user asks you to write a Terraform Policy: + +1. **Identify the policy type:** resource_policy, module_policy, or provider_policy +2. **Use the correct structure:** filter (optional), locals (optional), enforce (required) +3. **Remember:** ALL built-in functions need `core::` prefix +4. **For versions:** Always use `core::semverconstraint()`, never direct comparison +5. **Validate:** Check examples in this guide for patterns + +--- + +## Table of Contents + +1. [Critical Rules](#critical-rules) +2. [Policy Structure](#policy-structure) +3. [Policy Types](#policy-types) +4. [Core Functions Reference](#core-functions-reference) +5. [Semantic Versioning](#semantic-versioning) + +**See Also:** +- [Common Patterns](tfpolicy-author.md) - Common policy patterns and examples +- [tfpolicy-author](tfpolicy-author.md) - Complete authoring reference for this sub-skill + +--- + +## Critical Rules + +### ✅ Rule 1: ALL Functions Need core:: Prefix + +**ALWAYS use `core::` prefix for built-in Terraform functions** + +```hcl +# ✅ CORRECT +filter = core::try(attrs.encrypted, false) == true +is_valid = core::length([for b in local.checks : b if b]) > 0 +message = "Allowed: ${core::join(", ", local.versions)}" + +# ❌ WRONG - Will fail with "Unknown function" error +filter = try(attrs.encrypted, false) == true # Missing core:: prefix +# ❌ WRONG - core::anytrue does NOT EXIST in tfpolicy runtime +# is_valid = anytrue(local.checks) # Missing core:: prefix AND function doesn't exist +# is_valid = core::anytrue(local.checks) # Function does not exist — use core::length() instead +``` + +### ✅ Rule 2: Use Semantic Versioning for ALL Version Comparisons + +**NEVER use direct comparison operators for versions** + +```hcl +# ✅ CORRECT +condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0") + +# ❌ WRONG - Direct comparison doesn't work properly +condition = meta.version >= 4.0 && meta.version < 5.0 +``` + +### ✅ Rule 3: ALL Policy Types Support locals and filter + +**Don't avoid using locals or filter - they work in all policy types** + +```hcl +# ✅ All three policy types support this structure +resource_policy "aws_s3_bucket" "example" { + filter = # ✅ Supported + locals { ... } # ✅ Supported + enforce { ... } # ✅ Required +} + +module_policy "example" "check" { + filter = # ✅ Supported + locals { ... } # ✅ Supported + enforce { ... } # ✅ Required +} + +provider_policy "aws" "check" { + filter = # ✅ Supported + locals { ... } # ✅ Supported + enforce { ... } # ✅ Required +} +``` + +**Note:** Language servers during private beta may show false errors for `locals` in `provider_policy`. These are safe to ignore. + +--- + +## Policy Structure + +### Basic Template + +```hcl + "" "" { + # Optional: Pre-filter resources/modules/providers + filter = + + # Optional: Local variables for complex logic + locals { + variable_name = + } + + # Required: One or more enforcement rules + enforce { + condition = + error_message = "" + } + + # Optional: Additional enforce blocks + enforce { + condition = + error_message = "" + } +} +``` + +### Execution Flow + +1. **filter** - Applied first, determines which resources/modules/providers to evaluate +2. **locals** - Computed once per filtered item +3. **enforce** - Each block evaluated; all must pass for policy to pass + +### Performance Best Practices + +**Critical for large configurations — choose the pattern based on what the filter depends on:** + +1. **Top-level `core::getresources()` only when the filter is a known literal/constant** + - Use when the filter value is a hardcoded string, a fixed ID, or another stable literal — not derived from `attrs.*` + - Executes once for the entire policy evaluation; result is reused by every `resource_policy` block + - ❌ Do **not** use a top-level empty-filter call (`{}`) and then filter by `attrs.*` inside `resource_policy` — that is the prohibited anti-pattern (O(N²) with silent correctness bugs) + ```hcl + locals { + # OK — filter is a known literal, cached once for all resources + all_buckets = core::getresources("aws_s3_bucket", {}) + } + + resource_policy "aws_s3_bucket" "example" { + locals { + bucket_count = core::length(local.all_buckets) # Reuse cached value + } + } + ``` + +2. **Inline `core::getresources()` inside `resource_policy` when the filter depends on the resource's own attribute** + - Use when "every parent must have at least one compliant child" and the linking key is `attrs.id`, `attrs.arn`, or `attrs.name` + - The filter value is unknown at plan time (it's the current resource's own attribute), so a top-level cache is impossible + - Executes once per evaluated resource (apply-time); the lookup fully resolves once the resource is provisioned + - This is **not** a performance compromise — it is the **correct and required** pattern for parent+child presence enforcement + ```hcl + # NOTE: This policy contains a cross-resource reference that will not resolve during + # plan time, but the policy will run successfully during apply time. + resource_policy "aws_s3_bucket" "s3_block_public_access" { + locals { + public_access_block = core::getresources("aws_s3_bucket_public_access_block", { + bucket = attrs.id # filter depends on current resource — must be inline + }) + } + enforce { + condition = core::length(local.public_access_block) > 0 + error_message = "S3 bucket must have a public access block resource." + } + } + ``` + +3. **Use `filter` to reduce evaluation scope** + - Skip resources that don't need checking + - Significantly improves performance + +**See [Advanced Patterns Guide](tfpolicy-author.md#8--performance-optimization-verified) for detailed performance guidance** + +--- + +## Policy Types + +### 1. resource_policy + +**Purpose:** Validate Terraform resource configurations + +```hcl +resource_policy "aws_s3_bucket" "encryption_check" { + enforce { + condition = attrs.server_side_encryption_configuration != null + error_message = "S3 buckets must have encryption enabled" + } +} +``` + +**Available attributes:** +- `attrs.` - Resource attributes from configuration +- `meta.provider_type` - Provider type (e.g., `aws`) +- **⚠️ `meta.address` is UNDEFINED** for `resource_policy` in real plan evaluation — do not use it + +**⚠️ Understanding Provider Schema (Blocks vs Attributes):** + +Terraform providers expose their raw schema, where some attributes are **blocks** that require special handling: + +```hcl +# ❌ Wrong - blocks cannot be accessed directly +attrs.server_side_encryption_configuration.rules + +# ✅ Correct - blocks are lists, use [0] index +attrs.server_side_encryption_configuration[0].rules +``` + +**Common AWS provider blocks requiring `[0]` index:** +- `default_tags[0].*` (AWS provider configuration) +- `assume_role[0].*` (AWS provider configuration) +- `versioning[0].enabled` (S3 bucket) +- `server_side_encryption_configuration[0].*` (S3 bucket) +- `metadata_options[0].*` (EC2 instance) + +**Best practice:** Always use `core::length()` checks before accessing blocks: +```hcl +locals { + versioning_blocks = core::try(attrs.versioning, []) + versioning_enabled = core::length(local.versioning_blocks) > 0 ? + core::try(local.versioning_blocks[0].enabled, false) : false +} +``` + +**See [Verified Syntax Reference](verified-syntax.md#4--critical-blocks-vs-attributes-schema-distinction) for complete details** + +**Wildcards:** +```hcl +resource_policy "*" "all_resources" { + # Matches ALL resource types +} +``` + +### 2. module_policy + +**Purpose:** Validate Terraform module sources and versions + +```hcl +# Check all modules use approved registry (prefix-based using core::regex) +module_policy "*" "module_source_check" { + filter = meta.source != null + + locals { + # Prefix match: does source start with the approved namespace? + # core::contains() only does exact full-string matching — use core::regex() for prefix checks + is_approved = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null + } + + enforce { + condition = local.is_approved + error_message = "Modules must use an approved registry source. Current source: ${meta.source}" + } +} + +# Check specific module version with semver +module_policy "app.terraform.io/myorg/vpc/aws" "vpc_version" { + locals { + has_version = meta.version != null + meets_minimum = core::semverconstraint(meta.version, ">= 1.0.0") + } + + enforce { + condition = local.meets_minimum + error_message = "VPC module must be >= 1.0.0, got ${meta.version}" + } +} +``` + +**Available attributes:** +- `meta.source` - Module source (e.g., `app.terraform.io/org/module/provider`) +- `meta.version` - Module version (works with `core::semverconstraint()`) +- `meta.address` - Module address (e.g., `module.vpc`) + +**⚠️ Current Limitations (Private Beta):** +- ❌ `attrs.*` (module inputs) NOT accessible yet - work in progress +- ❌ `meta.tfe_workspace` NOT available - only in resource_policy + +**Targeting:** +- Use **full module source** to target specific module: `module_policy "app.terraform.io/myorg/vpc/aws"` +- Use `"*"` wildcard to match all modules: `module_policy "*"` +- ❌ Substring matching does NOT work: `module_policy "vpc"` won't match modules with "vpc" in source + +### 3. provider_policy + +**Purpose:** Validate provider versions and configurations + +```hcl +provider_policy "aws" "version_check" { + locals { + minimum_version = "4.0.0" + } + + enforce { + condition = core::semverconstraint(meta.version, ">= ${local.minimum_version}") + error_message = "AWS provider must be >= ${local.minimum_version}, got ${meta.version}" + } +} +``` + +**Available attributes:** +- `meta.source` - Full provider source (e.g., `registry.terraform.io/hashicorp/aws`) +- `meta.version` - Provider version (e.g., `4.67.0`) +- `meta.alias` - Provider alias (if configured) +- `attrs.*` - Provider configuration attributes (region, profile, etc.) + +> **Note:** `meta.name` and `meta.type` are NOT confirmed available in `reference/verified-syntax.md`. Do not rely on them — use `meta.source` to identify a provider and `meta.version` for version checks. + +**Accessing provider configuration with `attrs`:** + +```hcl +provider "aws" { + region = "us-west-2" + profile = "production" +} + +provider_policy "aws" "region_check" { + locals { + aws_region = core::try(attrs.region, "") + allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"] + } + + enforce { + condition = core::contains(local.allowed_regions, local.aws_region) + error_message = "AWS provider must use approved region. Got: ${local.aws_region}" + } +} +``` + +**⚠️ Provider configuration blocks require `[0]` index:** +```hcl +# AWS provider blocks (need [0] index) +attrs.default_tags[0].tags +attrs.assume_role[0].role_arn +attrs.endpoints[0].s3 +``` + +**Wildcards:** +```hcl +provider_policy "*" "all_providers" { + # Evaluates once per provider in configuration +} +``` + +--- + +## Core Functions Reference + +### ⚠️ String Function Limitations + +**Terraform Policy has VERY LIMITED string functions:** + +```hcl +# ✅ Get string length +core::length(string) +# Example: core::length(attrs.description) > 0 + +# ✅ Join list into string +core::join(separator, list) +# Example: core::join(", ", local.allowed_versions) +``` + +**✅ String functions available:** +- ✅ `core::startswith(string, prefix)` - Returns bool; e.g. `core::startswith(meta.version, ">")` ✅ +- ✅ `core::endswith(string, suffix)` - Returns bool +- ✅ `core::contains_substring(string, substr)` - Returns bool +- ❌ `core::contains(string, substring)` - Does NOT work for strings (only lists!) +- ✅ `core::split(separator, string)` - Splits string into list; e.g. `core::split("-", "80-443")` → `["80", "443"]` + +**✅ What IS Also Available — `core::regex(pattern, string)`:** +- Pattern and substring matching via `core::regex()` +- **Important:** `core::regex()` **throws** on no match (does NOT return null) — always wrap with `core::try()` + +```hcl +# Safe boolean pattern — use this idiom everywhere +locals { + # Substring check: does description contain "exception"? + has_exception = core::try(core::regex("NET-8 = exception", core::try(attrs.description, "")), null) != null + + # Prefix check: does source start with approved namespace? + is_approved_source = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null + + # Exact membership: still use core::contains() for lists + approved_types = ["gp3", "io1"] + is_approved_type = core::contains(local.approved_types, core::try(attrs.volume_type, "")) +} +``` + +**Note:** For prefix/suffix checking, prefer `core::startswith()` / `core::endswith()` over `core::regex()`. Use `core::split()` with `core::parseint()` for numeric string decomposition (e.g. port-range strings like `"80-443"`). + +### List Functions + +```hcl +# Check if list contains value +core::contains(list, value) +# Example: core::contains(["dev", "staging", "prod"], attrs.environment) + +# Get collection or string length (works on strings, lists, sets, maps!) +core::length(list_or_string_or_map) +# Example: core::length(local.violations) == 0 +# Example: core::length(attrs.description) > 0 # String length! +# Example: core::length(attrs.tags) > 0 # Map key count + +# Get map keys as list +core::keys(map) +# Example: core::keys(attrs.tags) +# Example: core::contains(core::keys(attrs.tags), "Environment") + +# Check if any element is true — ❌ core::anytrue() does NOT exist +# Use: core::length([for b in list_of_booleans : b if b]) > 0 + +# Check if all elements are true — ❌ core::alltrue() does NOT exist +# Use: core::length([for b in list_of_booleans : b if !b]) == 0 +``` + +### Safe Access + +```hcl +# Try expression with fallback +core::try(expression, default_value) +# Example: core::try(attrs.encrypted, false) +# Example: core::try(meta.version, "0.0.0") +``` + +**⚠️ CRITICAL: Cannot Check Attribute Existence Without try()** + +Direct attribute access fails when attributes don't exist, **even with null checks:** + +```hcl +# ❌ WRONG - Crashes with "This object does not have an attribute named 'region'" +has_region = attrs.region != null + +# ✅ CORRECT - Two-step safe access pattern +region_value = core::try(attrs.region, null) +has_region = local.region_value != null +``` + +**Why:** Terraform Policy cannot test attribute existence before accessing (no `"attr" in attrs` syntax). Always use `core::try()` first, then check the result. + +### Semantic Versioning + +```hcl +# Compare version against constraint +core::semverconstraint(version, constraint_string) +# Example: core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0") +``` + +### Resource Queries + +```hcl +# Query related resources +core::getresources(resource_type, filter_map) +# filter_map is REQUIRED. Pass {} to match everything, or { attr = value } +# for equality filtering. Caveat: candidates with unknown target +# attributes at plan time (e.g. references to to-be-created resource IDs) +# are conservatively included regardless of the filter value. +# Example: core::getresources("aws_security_group_rule", {}) +``` + +**⚠️ Filter caveat:** `core::getresources(type, { attr = value })` performs equality matching, but candidates whose target attribute is unknown at plan time (e.g. `bucket = aws_s3_bucket.x.id` for a resource being created in the same plan) are conservatively included. On first-time-create plans with cross-references you'll get every candidate back. Most reliable on update plans against existing infrastructure. See `reference/verified-syntax.md`. + +**CRITICAL: core::getresources() Attribute Access** + +Resources returned by `core::getresources()` have attributes **at the top level** (NOT through `.attrs`): + +```hcl +locals { + all_roles = core::getresources("aws_iam_role", {}) +} + +# ✅ CORRECT - Access attributes directly +role_names = [for role in local.all_roles : role.name] +filtered = [for role in local.all_roles : role if role.path == "/service/"] + +# ❌ WRONG - Do NOT use .attrs +role_names = [for role in local.all_roles : role.attrs.name] # ERROR! +``` + +**Why:** This is DIFFERENT from current resource context where you use `attrs.name`. Returned resources have a different structure. + +**Pattern for Cross-Resource Validation:** +```hcl +resource_policy "aws_iam_role" "check" { + locals { + role_attachments = core::getresources("aws_iam_role_policy_attachment", { + role = attrs.name # filter depends on current resource — must be inline + }) + has_attachment = core::length(local.role_attachments) > 0 + } +} +``` + +--- + +## Semantic Versioning + +### Constraint Operators + +| Operator | Meaning | Example | Matches | +|----------|---------|---------|---------| +| `=` | Exact version | `"= 4.67.0"` | 4.67.0 only | +| `!=` | Not equal | `"!= 4.50.0"` | Any except 4.50.0 | +| `>` | Greater than | `"> 4.0.0"` | 4.0.1, 4.1.0, 5.0.0, etc. | +| `>=` | Greater or equal | `">= 4.0.0"` | 4.0.0, 4.0.1, 5.0.0, etc. | +| `<` | Less than | `"< 5.0.0"` | 4.99.99, 3.0.0, etc. | +| `<=` | Less or equal | `"<= 5.0.0"` | 5.0.0, 4.99.99, etc. | +| `~>` | Pessimistic (patch) | `"~> 4.67.0"` | >= 4.67.0, < 4.68.0 | +| `~>` | Pessimistic (minor) | `"~> 4.0"` | >= 4.0.0, < 5.0.0 | + +### Multiple Constraints (AND logic) + +```hcl +# Both constraints must be satisfied +core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0") +core::semverconstraint(meta.version, ">= 4.0.0, != 4.50.0") +``` + +### OR Logic + +```hcl +locals { + # Version 4.x OR 5.x allowed + version_ok = core::semverconstraint(meta.version, "~> 4.0") || + core::semverconstraint(meta.version, "~> 5.0") +} +``` + +### Version Allowlist Pattern + +```hcl +locals { + allowed_versions = ["3.75.0", "3.80.0", "3.85.0"] + + # Check if current version matches any allowed version + version_checks = [ + for v in local.allowed_versions : + core::semverconstraint(meta.version, "= ${v}") + ] + + is_allowed = core::length([for b in local.version_checks : b if b]) > 0 +} + +enforce { + condition = local.is_allowed + error_message = "Version ${meta.version} not approved. Allowed: ${core::join(", ", local.allowed_versions)}" +} +``` + +--- + +> **Next:** [Advanced Patterns & Best Practices](tfpolicy-author.md) + +--- + +**Purpose:** Common patterns for writing Terraform policies +**Status:** All patterns verified during private beta (Updated: 2026-02-24) + +--- + +## Pattern 1: Required Attribute + +```hcl +resource_policy "aws_s3_bucket" "require_encryption" { + enforce { + condition = attrs.server_side_encryption_configuration != null + error_message = "S3 buckets must have encryption configured" + } +} +``` + +--- + +## Pattern 2: Attribute Must Match Value + +```hcl +resource_policy "aws_ebs_volume" "encryption" { + enforce { + condition = core::try(attrs.encrypted, false) == true + error_message = "EBS volumes must be encrypted" + } +} +``` + +--- + +## Pattern 3: Allowlist Check + +```hcl +resource_policy "aws_instance" "instance_type" { + locals { + allowed_types = ["t3.micro", "t3.small", "t3.medium"] + is_allowed = core::contains(local.allowed_types, attrs.instance_type) + } + + enforce { + condition = local.is_allowed + error_message = "Instance type ${attrs.instance_type} not allowed. Use: ${core::join(", ", local.allowed_types)}" + } +} +``` + +--- + +## Pattern 4: Tag Validation + +```hcl +resource_policy "*" "required_tags" { + filter = attrs.tags != null + + locals { + required_tags = ["Environment", "Owner", "CostCenter"] + # Count tags that are MISSING; if zero, all required tags are present + missing_tags = [ + for tag in local.required_tags : + tag if !core::contains(core::keys(attrs.tags), tag) + ] + has_all_tags = core::length(local.missing_tags) == 0 + } + + enforce { + condition = local.has_all_tags + error_message = "Resources are missing required tags: ${core::join(", ", local.required_tags)}" + } +} +``` + +--- + +## Pattern 5: Module Source Restriction + +```hcl +module_policy "*" "approved_sources" { + filter = meta.source != null + + locals { + # Exact allowlist: use core::contains() for explicit full-source matching. + # For prefix matching use core::startswith(), for pattern matching use core::regex(). + approved_sources = [ + "app.terraform.io/myorg/vpc/aws", + "app.terraform.io/myorg/database/aws", + "app.terraform.io/myorg/network/aws", + "registry.terraform.io/hashicorp/vpc", + "registry.terraform.io/hashicorp/s3-bucket" + ] + + is_approved = core::contains(local.approved_sources, meta.source) + } + + enforce { + condition = local.is_approved + error_message = "Module source not approved: ${meta.source}. Must be one of the explicitly allowed modules." + } +} +``` + +**Note:** `core::contains()` only supports exact full-string matching. For prefix or namespace matching, use `core::regex()`: +```hcl +# Prefix matching with core::regex() — matches any source under the approved namespace +locals { + is_approved_namespace = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null +} +enforce { + condition = local.is_approved_namespace + error_message = "Module source must be from app.terraform.io/myorg/ namespace. Got: ${meta.source}" +} +``` + +--- + +## Pattern 6: Provider Version Range + +```hcl +provider_policy "aws" "version_range" { + locals { + min_version = "4.0.0" + max_version = "5.0.0" + version_ok = core::semverconstraint(meta.version, ">= ${local.min_version}, < ${local.max_version}") + } + + enforce { + condition = local.version_ok + error_message = "AWS provider version ${meta.version} outside allowed range: >= ${local.min_version}, < ${local.max_version}" + } +} +``` + +--- + +## Pattern 7: Conditional Enforcement + +```hcl +resource_policy "aws_s3_bucket" "conditional_encryption" { + locals { + # Only enforce encryption for production buckets + is_production = core::contains(core::keys(attrs.tags), "Environment") && + attrs.tags["Environment"] == "production" + + has_encryption = attrs.server_side_encryption_configuration != null + } + + enforce { + # Skip check for non-production or enforce for production + condition = !local.is_production || local.has_encryption + error_message = "Production S3 buckets must have encryption enabled" + } +} +``` + +--- + +## Pattern 8: Multiple Checks with Detailed Errors + +```hcl +resource_policy "aws_security_group" "security_checks" { + locals { + # ✅ Use core::length() instead of core::anytrue() (which does NOT exist) + # Filter to SSH rules from internet; if list is non-empty, there's a violation + ssh_from_internet = [ + for rule in core::try(attrs.ingress, []) : + rule if (rule.from_port == 22 && core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0")) + ] + has_ssh_ingress = core::length(local.ssh_from_internet) > 0 + + # Check for proper description + has_description = attrs.description != null && core::length(attrs.description) > 0 + } + + enforce { + condition = !local.has_ssh_ingress + error_message = "Security groups must not allow SSH from the internet (0.0.0.0/0)" + } + + enforce { + condition = local.has_description + error_message = "Security groups must have a description" + } +} +``` + +--- + +## Pattern 9: Provider Configuration Policies + +**Validate provider configuration attributes and blocks:** + +```hcl +# Check provider region +provider_policy "aws" "approved_regions" { + locals { + aws_region = core::try(attrs.region, "") + allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"] + } + + enforce { + condition = core::contains(local.allowed_regions, local.aws_region) + error_message = "AWS provider must use approved region. Got: ${local.aws_region}" + } +} + +# Check provider blocks (need [0] index) +provider_policy "aws" "enforce_default_tags" { + locals { + # Provider blocks are lists + default_tags = core::try(attrs.default_tags, []) + has_default_tags = core::length(local.default_tags) > 0 + + # Access block attributes with [0] index + tags = local.has_default_tags ? + core::try(local.default_tags[0].tags, {}) : {} + + required_tags = ["Environment", "Owner", "CostCenter"] + tag_keys = core::keys(local.tags) + + # ✅ Use core::length() instead of core::alltrue() (which does NOT exist) + # Count required tags that are MISSING; if zero, all required tags are present + missing_required_tags = [ + for tag in local.required_tags : + tag if !core::contains(local.tag_keys, tag) + ] + has_all_required = core::length(local.missing_required_tags) == 0 + } + + enforce { + condition = local.has_default_tags + error_message = "AWS provider must configure default_tags block" + } + + enforce { + condition = local.has_all_required + error_message = "AWS provider default_tags must include: ${core::join(", ", local.required_tags)}" + } +} + +# Prevent hardcoded credentials +provider_policy "aws" "no_hardcoded_credentials" { + locals { + has_access_key = attrs.access_key != null + has_secret_key = attrs.secret_key != null + } + + enforce { + condition = !local.has_access_key && !local.has_secret_key + error_message = "AWS provider must NOT use hardcoded credentials (access_key/secret_key). Use environment variables or IAM roles instead." + } +} +``` + +**Key points:** +- `attrs.*` provides access to provider configuration +- Provider blocks (default_tags, assume_role) require `[0]` index +- Version checking uses `meta.version` with `core::semverconstraint()` +- Security checks (no hardcoded credentials) are important + +--- + +## Pattern 10: Cross-Resource Enforcement + +**Enforce that one resource type has a corresponding companion resource:** + +`aws_s3_bucket_server_side_encryption_configuration` is a dependent child of `aws_s3_bucket` — its `bucket` argument always references `attrs.id`. The filter value is derived from `attrs.*`, so the top-level cache pattern is prohibited (Mistake 13 in verified-syntax.md). Always use the inline filter pattern for S3 companion resources. + +```hcl +# NOTE: This policy contains a cross-resource reference that will not resolve +# during plan time, but the policy will run successfully during apply time. +resource_policy "aws_s3_bucket" "require_encryption_config" { + locals { + sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", { + bucket = attrs.id # filter derived from current resource's own attr — must be inline + }) + has_sse_config = core::length(local.sse_configs) > 0 + # rule is a Set — convert to list before indexing; guard first + sse_rules = local.has_sse_config ? core::try([for r in local.sse_configs[0].rule : r], []) : [] + has_sse_rule = core::length(local.sse_rules) > 0 + sse_apply_block = local.has_sse_rule ? core::try([for a in local.sse_rules[0].apply_server_side_encryption_by_default : a], []) : [] + has_apply_block = core::length(local.sse_apply_block) > 0 + sse_algorithm = local.has_apply_block ? core::try(local.sse_apply_block[0].sse_algorithm, "") : "" + } + + enforcement_level = "advisory" + + enforce { + condition = local.has_sse_config + error_message = "S3 bucket must have an aws_s3_bucket_server_side_encryption_configuration resource." + } + + enforce { + condition = local.sse_algorithm == "aws:kms" + error_message = "S3 bucket encryption must use aws:kms (found: ${local.sse_algorithm != "" ? local.sse_algorithm : "(none configured)"})" + } +} +``` + +**Key points:** +- The filter value (`attrs.id`) is the current resource's own attribute — a top-level cache is impossible; use the inline call +- Both checks (presence and KMS algorithm) live in one `resource_policy` block with multiple `enforce` blocks — never split checks on the same resource type into two blocks (SKILL.md Output Structure Rule 1) +- Always include the apply-time `# NOTE:` comment + +--- + +## Pattern 11: Resource Count Limits + +**Limit the total number of resources of a specific type:** + +```hcl +locals { + all_nat_gateways = core::getresources("aws_nat_gateway", {}) + nat_gateway_count = core::length(local.all_nat_gateways) + max_allowed = 3 +} + +resource_policy "aws_nat_gateway" "limit_count" { + enforce { + condition = local.nat_gateway_count <= local.max_allowed + error_message = "Maximum ${local.max_allowed} NAT gateways allowed (found: ${local.nat_gateway_count})" + } +} +``` + +**Key points:** +- Policy runs for each resource but references global count +- All resources will fail if limit is exceeded +- Use top-level locals to count once + +--- + +## Pattern 12: Cross-Resource Attribute Validation + +**Validate that one resource's attribute matches another resource's attribute:** + +```hcl +resource_policy "aws_subnet" "vpc_tag_match" { + filter = attrs.vpc_id != null + + locals { + # NOTE: This policy contains a cross-resource reference that will not resolve + # during plan time, but the policy will run successfully during apply time. + matching_vpcs = core::getresources("aws_vpc", { id = attrs.vpc_id }) + vpc = core::length(local.matching_vpcs) > 0 ? local.matching_vpcs[0] : null + vpc_env_tag = core::try(local.vpc.tags["Environment"], "") + subnet_env_tag = core::try(attrs.tags["Environment"], "") + tags_match = local.vpc_env_tag == local.subnet_env_tag + } + + enforce { + condition = local.tags_match + error_message = "Subnet Environment tag (${local.subnet_env_tag}) must match VPC Environment tag (${local.vpc_env_tag})" + } +} +``` + +**Key points:** +- `aws_vpc.id` is the linking attribute referenced by `attrs.vpc_id` — this is an attrs.*-derived key so the top-level cache + map-index pattern is prohibited (Mistake 13 in verified-syntax.md); always use inline `core::getresources` +- Use `core::try()` for safe attribute access +- Check both existence and value matching + +--- + +## Pattern 13: Sentinel Conversion - DMS Endpoint SSL Mode + +**Source policy:** HashiCorp PCI DSS library - `dms-endpoints-should-use-ssl.sentinel` + +**Conversion quality:** Perfect + +```hcl +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" + } +} +``` + +**Why this converts cleanly:** +- Single resource type +- Direct attribute check on planned values +- No cross-resource dependency or reference metadata +- Sentinel `collection.reject()` becomes one focused `enforce` condition + +--- + +## Pattern 14: Sentinel Conversion - Elasticsearch HTTPS Required + +**Source policy:** HashiCorp PCI DSS library - `elasticsearch-https-required.sentinel` + +**Conversion quality:** Good + +```hcl +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'" + } +} +``` + +**Why this is `Good` instead of `Perfect`:** +- The Sentinel policy uses helper functions and nested map lookups; tfpolicy rewrites that logic into direct block access with `core::try()` +- The enforcement intent is preserved, but the structure is idiomatic tfpolicy rather than one-to-one + +--- + +## Pattern 15: Sentinel Conversion - EventBridge Bus Must Have Attached Policy + +**Source policy:** HashiCorp PCI DSS library - `eventbridge-custom-event-bus-should-have-attached-policy.sentinel` + +**Conversion quality:** Limited + +```hcl +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" + } +} +``` + +**Why this is only `Limited`:** +- This relies on value matching through `core::getresources()`, not Terraform graph/reference metadata +- It works best when `event_bus_name` is explicit and already resolved +- New resources with unresolved references can produce different behavior from Sentinel or fail to match on first creation + +--- + + +> **Previous:** [Quick Start Guide](tfpolicy-author.md) +> **Back to:** [Main README](../README.md) diff --git a/.agents/skills/terraform-policy/references/tfpolicy-test.md b/.agents/skills/terraform-policy/references/tfpolicy-test.md new file mode 100644 index 0000000..af62185 --- /dev/null +++ b/.agents/skills/terraform-policy/references/tfpolicy-test.md @@ -0,0 +1,2111 @@ +--- +name: tfpolicy-test +description: Expert agent for testing Terraform policies. Helps write and debug `.policytest.hcl` files, design resource mocks (`attrs` / `prior_attrs`), reason about runner behavior, and verify policy correctness before promotion to enforcement. +license: MPL-2.0 +metadata: + copyright: Copyright IBM Corp. 2026 + version: "0.1.0" +--- + +# tfpolicy-test + +## Description +Expert agent for testing Terraform policies. Helps write `.policytest.hcl` files, design resource and module mocks, use `expect_failure` correctly, mock cross-resource lookups for `core::getresources()`, and reason about current `tfpolicy test` runner behavior. + +## Use When +- The user has an existing policy and wants to write or improve tests for it. +- The user is debugging a failing or unexpectedly-passing test, or asking why a mock behaves the way it does. +- The user is writing `.policytest.hcl` files, `policytest { targets = [...] }` blocks, `resource {}` / `module {}` mocks, or using `expect_failure` / `skip`. +- The user is testing operation-aware policies and needs to mock `attrs` and/or `prior_attrs` for create/update/delete scenarios. +- The user is asking how to mock cross-resource lookups (e.g. `aws_s3_bucket_versioning` for `core::getresources` patterns). +- The user is investigating a runner caveat (mocks evaluated regardless of `operations` scope, `expect_failure` not supported on `data` blocks, etc.). + +**Do not use this skill when:** +- The user is writing the policy itself rather than its test — use [`tfpolicy-author`](tfpolicy-author.md). +- The user is converting a Sentinel test to a `.policytest.hcl` test — start with [`tfpolicy-author`](tfpolicy-author.md), then return here for test-side refinements. + +## Capabilities + +### 1. Write `.policytest.hcl` Files from Existing Policies +Generate a focused test file that exercises the passing and failing paths of a policy, including `expect_failure = true` cases and any required `prior_attrs` mocks. For policies that use `input` blocks, generate separate test files per input scenario using `inputs {}` (plural) to override default values. + +### 2. Design Mocks for Cross-Resource Lookups +Build the `resource {}` blocks needed for `core::getresources()` filters to match correctly (parent + child resources, `skip = true` on lookup-only resources, etc.). + +### 3. Diagnose Runner Behavior +Explain why a mock fails, passes, or crashes. Cover the current caveats: `operations` scope is not yet honored by the runner, `expect_failure` is rejected on `data` blocks, omitted attributes crash unless wrapped in `core::try()`. + +### 4. Recommend Test Organization +Decide when to split into multiple `.policytest.hcl` files (per-policy targeting) versus consolidating, and how to keep mocks aligned with the policy's actual evaluation target. + +### 5. Generate Edge-Case Test Scenarios +For every generated test file, mandate test cases covering: +- **Missing attribute** — resource mock where the attribute is **entirely omitted** (most common real-world gap; crashes policies that don't use `core::try()`) +- **Empty collection** — resource with an empty list `[]` or empty map `{}` where the policy expects a non-empty value +- **Boundary conditions** — exact threshold values (e.g. port at limit, count at max) +- **Null attribute** — resource where the checked attribute is **explicitly set to `null`**. ⚠️ This case must only be included when the rules below permit it — do not add it unconditionally. + +Every generated `.policytest.hcl` must include at least one `expect_failure = true` resource that exercises a missing-attribute scenario. + +**Before finalizing each test case, verify polarity against the policy's `condition`.** For each `expect_failure = true` case, confirm the condition evaluates to `false` for that mock. For each pass case (no `expect_failure`), confirm it evaluates to `true`. A fail case the policy actually passes produces `Missing expected failure`; a pass case the policy actually fails produces an unexpected violation. + +**🔴 Null test case decision rules:** + +> **Key fact:** `core::try(attrs.field, default)` triggers the fallback **only when the attribute key is absent**. When a mock explicitly sets `attrs.field = null`, `core::try` returns `null` — not the default. This asymmetry means a `null` case and an omitted-attribute case exercise different code paths. + +**For every attribute you are about to set to `null` in a test mock — regardless of attribute name, nesting level, or resource type — apply this check before adding the case:** + +- **Two-step pattern** (`raw = core::try(attrs.field, null)` then `val = raw != null ? raw : []`) — `null` is explicitly normalized to a safe default. Add a `pass` case (no `expect_failure`) with the attribute set to `null` to verify this normalization works. +- **Explicit non-compliance check** (`condition = val != null && val != ""`) — `null` is intentionally treated as non-compliant. Add a `fail` case (`expect_failure = true`) with the attribute set to `null`. +- **Single-step only** (`val = core::try(attrs.field, [])`) without an explicit null guard — `null` is **not** normalized and will crash downstream expressions (`for val in null`, `core::length(null)`). **Do NOT add a `null` case.** Fix the policy to use the two-step pattern instead. + +**Never add `fail_*_null` when the policy treats `null` the same as the safe default** (`false` / `[]`). A `null` case that the policy silently normalizes to compliant produces a `Missing expected failure` error — caused by the test itself, not a real policy bug. + +The three rules above apply at every nesting level. For a scalar accessed as `core::try(local.list[0].attr, default)`, apply the same single-step / two-step determination at that specific access point. + +### 6. Mock Cross-Resource Lookups for `core::getresources()` +When the policy under test uses `core::getresources(resource_type, filter)`, the test runner resolves the lookup against the resources declared in the same `.policytest.hcl` file. For the lookup to return the expected results: + +1. **Filter attribute values must match exactly.** The companion resource mock must declare the linking attribute with the exact value that equals `attrs.` of the parent resource at evaluation time. Resource names in the mock do not affect matching — only attribute values do. +2. **Use `skip = true` on companion resources.** Resources that should be visible to `core::getresources()` but must not be evaluated directly by the policy should be declared with `skip = true`. Without this, the runner evaluates them as standalone resources, which may produce unexpected results. +3. **Test the no-match case explicitly.** Include a test case where the companion resource is absent or has a non-matching filter attribute value. In this case `core::getresources()` returns an empty list — verify that the policy handles this as intended (e.g. treats the parent as non-compliant if a companion is required, or compliant if companion is optional). +4. **Test the match case explicitly.** Include a test case where the companion resource is present with matching attributes to confirm the lookup resolves correctly. +5. **`filter` on the policy affects ALL resources in the test file when companions are present.** When the policy uses a top-level `core::getresources()`-based `filter` (e.g. `filter = core::length(local.all_companions) > 0`), **every** parent resource in the test file is evaluated as soon as any companion resource is declared in that file. An unlinked parent (`is_linked = false`) then fails `is_linked && check_attr == VALUE` → violation — even if `check_attr` has the correct value. Do NOT include a "pass because not linked" case in the same file as companion resources. Omit it entirely; the meaningful cases are: (a) linked + correct attr → pass, (b) linked + wrong attr → fail. If the policy uses `filter = local.has_qualifying` (qualifying companions only), unlinked parents are not evaluated when no qualifying companions exist — but they still get evaluated when qualifying companions are present, so the same rule applies: no "unlinked pass" case in a file that also declares companion resources. + +### 7. Enforce Explicit `policytest { targets }` Blocks +Best practice: every generated `.policytest.hcl` should include an explicit `policytest { targets = [".policy.hcl"] }` block when multiple policies exist in the same directory. Without it, `tfpolicy test` evaluates the mock against every policy in the directory — causing a test written for one policy to run against a different policy, producing misleading pass/fail results. + +### 8. Verify Default Values Match the Source Intent +When generating test cases for policies that use `core::try(attr, default)`, confirm that the default value in the policy matches the intended behavior for absent or null attributes. A wrong default silently passes resources that should fail. For each boolean flag or enum attribute, add a comment in the test explaining the expected behavior when the attribute is omitted versus explicitly set to null. + +## Knowledge Base + +The bulk of this skill is the testing guide: + +Cross-cutting facts shared with the sibling skills live in: + +- [`verified-syntax.md`](verified-syntax.md) — verified Terraform Policy syntax, function names, runtime limitations. Anything in conflict with the testing guide should defer to this file. + +## See Also +- [`tfpolicy-author`](tfpolicy-author.md) — write the policy under test. +- [`tfpolicy-author`](tfpolicy-author.md) — migrate Sentinel tests alongside the policies. + +--- + + +## Table of Contents + +1. [Testing Basics](#testing-basics) +2. [Resource Policy Testing](#resource-policy-testing) +3. [Module Policy Testing](#module-policy-testing) +4. [Provider Policy Testing](#provider-policy-testing) +5. [Advanced Techniques](#advanced-techniques) +6. [Best Practices](#best-practices) + +--- + +## Testing Basics + +### Test File Structure + +```hcl +# Optional: specify which policy files to test +policytest { + targets = ["policy-file-1.policy.hcl", "policy-file-2.policy.hcl"] +} + +# Mock resources with expected outcomes +resource "aws_s3_bucket" "passing_bucket" { + attrs = { + bucket = "my-secure-bucket" + versioning = [{ enabled = true }] + } +} + +resource "aws_s3_bucket" "failing_bucket" { + expect_failure = true + attrs = { + bucket = "my-insecure-bucket" + versioning = [{ enabled = false }] + } +} +``` + +**Full test file structure with all supported blocks:** +```hcl +policytest { + targets = ["policy-file.policy.hcl"] +} + +# Override input defaults for this test file +inputs { + port = 90 +} + +resource "aws_security_group_rule" "should_fail_port_90" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +} +``` + +**Key Points:** +- **Best practice: include `policytest { targets = [".policy.hcl"] }` when multiple policies exist in the same directory.** Without it, `tfpolicy test` evaluates the mock against every policy in the directory — a test resource intended for one policy will silently run against others, producing misleading results. `expect_failure = true` on such a resource can pass for the wrong reason. +- `expect_failure = true` applies to ALL policies evaluating the resource +- **`expect_failure` is ONLY valid on `resource {}` blocks** — using it on `data {}` blocks causes `Unsupported argument` error + +> **⚠️ Critical: `tfpolicy test` does NOT evaluate `error_message`** +> +> `tfpolicy test` only evaluates the `condition` expression. It **never** evaluates or interpolates the `error_message` string. This means: +> - A policy with `error_message = "Failed: ${meta.address}"` will pass all `tfpolicy test` runs even though `meta.address` is UNDEFINED and will crash every resource at runtime. +> - **Only `terraform plan --policies=` evaluates `error_message`** — always validate generated policies against a real Terraform plan to catch this class of bug. +- **CRITICAL:** Omitted attributes cause evaluation errors if accessed directly. Always use `core::try()` to handle missing attributes + +### Test Execution Behavior + +1. **Multiple policies evaluate same resources** - All matching policies run against all matching test resources +2. **Tests continue on failure** - All tests run to completion, not stopping at first failure +3. **Exit codes for CI/CD**: + - Exit 0: All tests pass (including expected failures) + - Exit 1: Unexpected failures or errors + +**CI/CD Usage:** +```bash +tfpolicy test --policies=./policies --tests=./tests +if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; fi +``` + +--- + +## Resource Policy Testing + +### Basic Syntax + +```hcl +resource "resource_type" "test_name" { + expect_failure = true/false # Optional — ONLY valid on resource blocks, NOT data blocks + skip = true/false # Optional + attrs = { + # All resource attributes + } +} +``` + +> **⚠️ `expect_failure` is NOT supported on `data {}` blocks.** Using it on a data block causes `Unsupported argument "expect_failure"`. Only mock `resource {}` blocks support this attribute. + +### Omitted Attributes Behavior + +**CRITICAL:** Attributes accessed by policies MUST be provided in test mocks or wrapped with `core::try()`. + +**Direct Access (Causes Error):** +```hcl +# Policy +resource_policy "aws_ebs_volume" "check" { + enforce { + condition = attrs.encrypted == true # Direct access + } +} + +# Test - ERROR if encrypted is omitted +resource "aws_ebs_volume" "test" { + attrs = { + size = 100 + # encrypted omitted - causes "This object does not have an attribute named 'encrypted'" + } +} +``` + +**Safe Access with core::try():** +```hcl +# Policy +resource_policy "aws_ebs_volume" "check" { + locals { + encrypted = core::try(attrs.encrypted, false) # Safe access + } + enforce { + condition = local.encrypted == true + } +} + +# Test - Works even if encrypted is omitted +resource "aws_ebs_volume" "test" { + attrs = { + size = 100 + # encrypted omitted - core::try() returns false (default value) + } +} +``` + +**Rule:** Only attributes NOT accessed by the policy can be safely omitted. All accessed attributes must either: +1. Be provided in the mock's `attrs = {}` block, OR +2. Be accessed via `core::try()` in the policy + +### Testing Operation-Aware Policies + +Policies can scope themselves to specific plan operations via `operations = ["create", "update", "delete"]`. Test mocks support a matching `prior_attrs = { ... }` block alongside `attrs = { ... }`, so create, update, and delete-gate policies are all fully testable. + +**Mock shape per operation:** + +| Operation being tested | Provide `attrs` | Provide `prior_attrs` | +| --- | --- | --- | +| `create` | ✅ planned values | — | +| `update` | ✅ planned values | ✅ pre-change values | +| `delete` | — | ✅ pre-change values | + +**Create / update policy (planned values only):** + +```hcl +# Policy +resource_policy "tfe_workspace" "require_project" { + operations = ["create", "update"] + enforce { + condition = core::try(attrs.project_id, "") != "" + error_message = "tfe_workspace must have project_id set." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-require-project.policy.hcl"] } + +resource "tfe_workspace" "with_project" { + attrs = { project_id = "prj-123" } +} + +resource "tfe_workspace" "missing_project" { + expect_failure = true + attrs = {} +} +``` + +**Update policy (reads both `attrs` and `prior_attrs`):** + +```hcl +# Policy — block downgrades +resource_policy "tfe_workspace" "no_downgrade" { + operations = ["update"] + enforce { + condition = core::try(attrs.terraform_version, "") == core::try(prior_attrs.terraform_version, "") + || core::try(attrs.terraform_version, "") > core::try(prior_attrs.terraform_version, "") + error_message = "terraform_version downgrade is not allowed." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-no-downgrade.policy.hcl"] } + +resource "tfe_workspace" "upgrade_ok" { + attrs = { terraform_version = "1.10.0" } + prior_attrs = { terraform_version = "1.9.0" } +} + +resource "tfe_workspace" "downgrade_blocked" { + expect_failure = true + attrs = { terraform_version = "1.5.0" } + prior_attrs = { terraform_version = "1.9.0" } +} +``` + +**Delete-gate policy (pre-change state only):** + +```hcl +# Policy +resource_policy "tfe_workspace" "deny_delete_without_tag" { + operations = ["delete"] + locals { + prior_tag_names = core::try(prior_attrs.tag_names, []) + } + enforce { + condition = core::contains(local.prior_tag_names, "delete") + error_message = "Add 'delete' tag before destroying a workspace." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-deny-delete-without-tag.policy.hcl"] } + +resource "tfe_workspace" "has_delete_tag" { + prior_attrs = { tag_names = ["delete", "prod"] } +} + +resource "tfe_workspace" "missing_delete_tag" { + expect_failure = true + prior_attrs = { tag_names = ["prod"] } +} +``` + +> **Note:** The runner currently evaluates each mock against every policy listed in `targets` regardless of the policy's `operations` scope. Keep your `.policytest.hcl` file targeted at a single policy (or a set of policies that share the same operation scope), and only supply the `attrs` / `prior_attrs` fields that policy actually reads. + +### Provider Schema Awareness + +The structure of `attrs = {}` depends on provider schema. Consult provider docs to determine if attributes are blocks or direct values. + +**Example - Blocks vs Attributes:** +```hcl +resource "aws_instance" "test" { + attrs = { + instance_type = "t2.micro" # Direct attribute + + # Block (requires array of maps) + default_tags = [{ + tags = { + Environment = "Production" + } + }] + } +} +``` + +**In test files:** Use `=` for blocks (not `{}` syntax used in policy files) + +### Cross-Resource References + +Reference other test resources within the SAME file using `resource_type.name.attrs.attribute`: + +```hcl +resource "aws_security_group" "app_sg" { + attrs = { + name = "app-security-group" + } +} + +resource "aws_instance" "app_server" { + attrs = { + vpc_security_group_ids = [aws_security_group.app_sg.attrs.name] + } +} +``` + +**Limitation:** References cannot span across test files. + +### The skip Attribute + +Resources with `skip = true`: +- Are added to resource graph +- Are NOT evaluated by policies +- CAN be referenced by other resources +- ARE included in `core::getresources()` results + +```hcl +resource "aws_ebs_volume" "available_for_reference" { + skip = true + attrs = { + volume_id = "vol-12345" + } +} + +resource "aws_instance" "server" { + attrs = { + ebs_block_device = [{ + volume_id = aws_ebs_volume.available_for_reference.attrs.volume_id + }] + } +} +``` + +**Use skip only when:** Resource is referenced or needed in getresources() counts. + +### Testing Filters + +If a resource doesn't match the filter, it's NOT evaluated (test passes): + +```hcl +# Policy with filter +resource_policy "aws_s3_bucket" { + filter = attrs.bucket_prefix == "secure-" + enforce { + condition = attrs.versioning[0].enabled == true + } +} + +# Test - doesn't match filter, so passes +resource "aws_s3_bucket" "filtered_out" { + attrs = { + bucket_prefix = "public-" # Doesn't match filter + versioning = [{ enabled = false }] + } +} +``` + +**Best Practice:** Test both resources that match and don't match the filter. + +### Resource Policy Meta Attributes + +**IMPORTANT:** Meta attributes for `resource_policy` behave differently in mock tests vs real terraform plan evaluation. + +**Available Meta Attributes by Evaluation Mode:** + +| Meta Attribute | Mock Tests (`tfpolicy test`) | Real Plans (`terraform plan --policies=`) | +|----------------|------------------------------|------------------------------------------| +| `meta.provider_type` | ❌ UNDEFINED | ✅ Available (e.g., "aws", "azurerm") | +| `meta.type` | ❌ UNDEFINED | ❌ UNDEFINED | +| `meta.address` | ❌ UNDEFINED | ❌ UNDEFINED | + +**Example:** +```hcl +# Policy using meta.provider_type +resource_policy "aws_ebs_volume" "check_provider" { + enforce { + condition = core::try(meta.provider_type, "UNDEFINED") == "aws" + error_message = "Provider type: ${core::try(meta.provider_type, "UNDEFINED")}" + } +} +``` + +**Test behavior:** +- With `tfpolicy test`: `meta.provider_type` returns UNDEFINED (test may fail) +- With `terraform plan --policies=`: `meta.provider_type` returns "aws" (test passes) + +**Best Practice:** When using `meta.provider_type` in policies, always wrap with `core::try()` and note that mock tests cannot fully validate this behavior. Test with real terraform plans for complete validation. + +--- + +## Module Policy Testing + +### Module Test Syntax + +```hcl +module "source" "test_name" { + expect_failure = true/false # Optional + meta = { + source = "registry.terraform.io/namespace/name" + address = "module.name" + version = "1.0.0" + } +} +``` + +**Available meta attributes:** +- `source` - Module source +- `address` - Module address (e.g., "module.database") +- `version` - Module version + +**Note:** Modules use `meta` only (no `attrs`) + +### Example: Module Source Allowlist + +**Policy:** +```hcl +locals { + allowed_sources = [ + "registry.terraform.io/hashicorp/aws", + "registry.terraform.io/terraform-aws-modules/vpc/aws" + ] +} + +module_policy "*" "approved_sources" { + filter = meta.source != null + enforce { + condition = core::contains(local.allowed_sources, meta.source) + error_message = "Unauthorized module source: ${meta.source}" + } +} +``` + +**Test:** +```hcl +# Passing +module "registry.terraform.io/hashicorp/aws" "approved" { + meta = { + source = "registry.terraform.io/hashicorp/aws" + address = "module.database" + version = "1.0.0" + } +} + +# Failing +module "registry.terraform.io/acme-corp/database" "unauthorized" { + expect_failure = true + meta = { + source = "registry.terraform.io/acme-corp/database" + address = "module.db" + version = "2.0.0" + } +} +``` + +### Example: Module Version Enforcement + +**Policy:** +```hcl +module_policy "registry.terraform.io/hashicorp/aws" "version_check" { + filter = meta.source == "registry.terraform.io/hashicorp/aws" + enforce { + condition = core::semverconstraint(meta.version, ">= 4.0.0") + error_message = "Module must use version >= 4.0.0, found ${meta.version}" + } +} +``` + +--- + +## Provider Policy Testing + +### Provider Test Syntax + +```hcl +provider "type" "test_name" { + expect_failure = true/false # Optional + meta = { + source = "registry.terraform.io/namespace/name" + } +} +``` + +**Available meta attributes:** +- `source` - Provider source (e.g., "registry.terraform.io/hashicorp/aws") + +**Note:** Provider type (e.g., "aws") goes in block declaration, not meta. + +### Example: Provider Source Allowlist + +**Policy:** +```hcl +locals { + allowed_provider_sources = [ + "registry.terraform.io/hashicorp/aws", + "registry.terraform.io/hashicorp/azurerm" + ] +} + +provider_policy "aws" { + enforce { + condition = core::contains(local.allowed_provider_sources, meta.source) + error_message = "Provider source '${meta.source}' is not approved" + } +} +``` + +**Test:** +```hcl +# Passing +provider "aws" "official" { + meta = { + source = "registry.terraform.io/hashicorp/aws" + } +} + +# Failing +provider "aws" "unofficial" { + expect_failure = true + meta = { + source = "registry.terraform.io/acme-corp/aws" + } +} +``` + +### Common Provider Patterns + +1. **Official providers only**: Check `meta.source == "registry.terraform.io/hashicorp/aws"` +2. **Version constraints**: Use `core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0")` +3. **Allowlist by type**: Create separate provider_policy for each allowed type + +--- + +## Advanced Techniques + +### Data Source Mocking + +```hcl +data "aws_ami" "ubuntu" { + attrs = { + id = "ami-12345" + name = "ubuntu-20.04" + } +} + +# Policy can reference it +resource_policy "aws_instance" { + enforce { + condition = attrs.ami == data.aws_ami.ubuntu.attrs.id + } +} +``` + +### Workspace Context Limitations + +❌ **Not Available:** `terraform.workspace` or workspace context + +**Valid traversal roots:** +- `input` - Input variables +- `local` - Local variables +- `attrs` - Resource/data source attributes +- `meta` - Metadata + +**Workarounds:** +1. Use resource tags for environment-based logic +2. Separate policy sets per environment in HCP Terraform +3. CI/CD-level enforcement based on workspace name +4. Tag-based validation + +### Testing Collections + +```hcl +resource "aws_security_group" "multiple_rules" { + attrs = { + ingress = [ + { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + }, + { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + ] + } +} +``` + +### Testing Null/Missing Attributes + +**Only works if policy uses `core::try()`:** + +```hcl +# Policy must use core::try() to handle missing attributes +resource_policy "aws_s3_bucket" "check" { + locals { + encryption = core::try(attrs.server_side_encryption_configuration, null) + } + enforce { + condition = local.encryption != null + error_message = "Encryption required" + } +} + +# Test - omitted attribute handled by core::try() +resource "aws_s3_bucket" "no_encryption" { + expect_failure = true + attrs = { + bucket = "my-bucket" + # server_side_encryption_configuration omitted - handled by core::try() + } +} +``` + +**Without `core::try()`, omitting accessed attributes causes evaluation errors.** + +--- + +### Testing Policies with `input` Blocks + +Policies that use `input` blocks can have their input values overridden per test file using an **`inputs {}`** block (plural). This allows you to test the policy behaviour under different configurations without changing the policy itself. + +> ⚠️ The block is `inputs {}` (plural) — using `input {}` (singular) throws `Unsupported block type` error. + +```hcl +# Policy (test.policy.hcl) +input "port" { + type = number + default = 22 +} + +resource_policy "aws_security_group_rule" "no_open_ingress" { + filter = core::try(attrs.type, "") == "ingress" + locals { + covers_port = core::try(attrs.from_port <= input.port && attrs.to_port >= input.port, false) + } + enforce { + condition = !local.covers_port + error_message = "Ingress rule covers restricted port ${input.port}." + } +} +``` + +```hcl +# Test file 1: test with default port (22) +policytest { + targets = ["test.policy.hcl"] +} +# No inputs block — uses input.port default = 22 + +# PASS: port range 80-443 does not cover default port 22 +resource "aws_security_group_rule" "pass_default_port" { + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + } +} + +# FAIL: port range 1-1024 covers default port 22 +resource "aws_security_group_rule" "fail_default_port" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 1 + to_port = 1024 + protocol = "tcp" + } +} +``` + +```hcl +# Test file 2: test with custom port (90) +policytest { + targets = ["test.policy.hcl"] +} + +inputs { + port = 90 # override default of 22 +} + +# FAIL: port range 80-443 DOES cover custom port 90 +resource "aws_security_group_rule" "fail_custom_port" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + } +} +``` + +**Key points:** +- Each test file can have its own `inputs {}` block with different values — use separate `.policytest.hcl` files per input scenario +- When no `inputs {}` block is present, the policy's `default` values are used +- Always add a comment to each test file stating which input values it assumes — prevents confusion when the same resource mock produces different results under different inputs +- **Note:** input values can only be overridden at policy-set level in HCP Terraform for live enforcement — `inputs {}` in test files is for test-time validation only + +Every generated `.policytest.hcl` **must** include test cases for the following scenarios. Missing any of these is a test coverage gap: + +| Scenario | Mock pattern | Why it matters | +|----------|-------------|----------------| +| **Missing attribute** (omitted entirely) | `attrs = { bucket = "x" }` — target attribute not present | Crashes policies that don't use `core::try()`; most common real-world gap | +| **Null attribute** | `attrs = { ..., field = null }` | Tests `core::try()` default handling | +| **Empty list** | `attrs = { ..., items = [] }` | Policies expecting non-empty collections must handle `[]` | +| **Empty string** | `attrs = { ..., value = "" }` | String-check policies must not treat `""` as compliant | +| **Boundary value** | Exact threshold (e.g. port = 443, count = max_allowed) | Off-by-one errors in range/count checks | + +```hcl +# ✅ Missing attribute — must fail (tests core::try() default) +resource "aws_s3_bucket" "missing_encryption" { + expect_failure = true + attrs = { + bucket = "test-bucket" + # server_side_encryption_configuration intentionally omitted + } +} + +# ✅ Empty list — must fail +resource "aws_s3_bucket" "empty_encryption_rules" { + expect_failure = true + attrs = { + bucket = "test-bucket" + server_side_encryption_configuration = [] + } +} +``` + +--- + +## Best Practices + +### General Guidelines + +1. **Use descriptive names**: `encrypted_volume_passes` not `test1` +2. **Organize by scenario**: Group passing/failing tests with comments +3. **Test edge cases**: Always include missing-attribute, null, empty-collection, and boundary-value scenarios — see [Mandatory Edge-Case Checklist](#mandatory-edge-case-checklist) above +4. **Consult provider schemas**: Match provider's block/attribute structure + +### Testing Strategy + +1. **Separate concerns**: One test file per policy file +2. **Use skip strategically**: Only when resource is referenced or in getresources() counts +3. **Test both sides of filters**: Resources that match and don't match +4. **Document complex references**: Add comments explaining relationships + +### File Organization + +``` +policies/ +├── cis-4.1-deny-public-ssh.policy.hcl +├── cis-4.1-deny-public-ssh.policytest.hcl +├── cis-4.2-deny-public-rdp.policy.hcl +└── cis-4.2-deny-public-rdp.policytest.hcl +``` + +--- + +## Quick Reference Table + +| Policy Type | Test Block | Available Attributes | +|-------------|------------|---------------------| +| resource_policy | `resource "type" "name" { attrs = {...} }` | `attrs.*`, `meta.provider_type` (real plans only) | +| module_policy | `module "source" "name" { meta = {...} }` | `meta.source`, `meta.address`, `meta.version` | +| provider_policy | `provider "type" "name" { meta = {...} }` | `meta.source` | +| data source | `data "type" "name" { attrs = {...} }` | `attrs.*` | + +### Common Features + +| Feature | Syntax | Scope | +|---------|--------|-------| +| Test target | `policytest { targets = ["file.policy.hcl"] }` | Optional (best practice when multiple policies in dir) | +| Override input values | `inputs { key = value }` | Per test file — uses policy `default` if omitted | +| Expected failure | `expect_failure = true` | All policies | +| Skip evaluation | `skip = true` | Resources only | +| Cross-resource ref | `resource_type.name.attrs.attribute` | Same file only | +| Omitted attributes | Don't specify in `attrs = {}` | Causes error unless policy uses `core::try()` | + +--- + +## Advanced Testing Patterns (Real-World Learnings) + +### Cross-Resource Lookup Pattern + +**Problem:** Need to enforce that every S3 bucket has a corresponding encryption configuration. + +**Solution:** Evaluate buckets, look up encryption configs via `core::getresources()`. + +```hcl +# Top-level: Get all encryption configs once +locals { + all_encryption_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {}) +} + +# Resource-level: Find matching config for each bucket +resource_policy "aws_s3_bucket" "require_encryption" { + locals { + matching_configs = [ + for config in local.all_encryption_configs : + config if config.bucket == attrs.bucket + ] + } + enforce { + condition = core::try(local.matching_configs[0], null) != null + error_message = "Bucket must have encryption config" + } +} +``` + +**Test Structure:** +```hcl +# Evaluated resource - NO skip +resource "aws_s3_bucket" "test" { + attrs = { bucket = "test" } +} + +# Looked-up resource - YES skip (but still visible to core::getresources) +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + skip = true + attrs = { + bucket = aws_s3_bucket.test.bucket + rule = [{ ... }] # Must be array! + } +} +``` + +**Key Points:** +- ✅ Resources with `skip = true` ARE visible to `core::getresources()` +- ✅ Use top-level `locals` for `core::getresources()` (performance) +- ✅ Always evaluate the resource that must exist, look up optional ones + +### The Two-Check Pattern + +**Problem:** Need to check two related attributes to determine compliance. + +**Example:** S3 must use customer-managed KMS keys (not AWS-managed or AES256). + +AWS encryption types: +- SSE-S3 (AES256) - S3-managed keys ❌ +- SSE-KMS without key ID - AWS-managed "aws/s3" key ❌ +- SSE-KMS with key ID - Customer-managed key ✅ + +**Why one check fails:** +```hcl +# ❌ Only checks algorithm +condition = attrs.sse_algorithm == "aws:kms" +# PASSES even without kms_master_key_id (uses AWS-managed key!) + +# ❌ Only checks key ID +condition = attrs.kms_master_key_id != "" +# PASSES even with "AES256" algorithm (not using KMS!) +``` + +**Correct: Check both** +```hcl +locals { + sse_algorithm = core::try(attrs.encryption[0].sse_algorithm, "") + kms_key_id = core::try(attrs.encryption[0].kms_master_key_id, "") +} +enforce { + condition = local.sse_algorithm == "aws:kms" && local.kms_key_id != "" + error_message = "Must use customer-managed KMS. Found algorithm: '${local.sse_algorithm}', key specified: ${local.kms_key_id != ""}" +} +``` + +### Test File Size Limitation + +**Discovery:** With two-policy approach (one policy for buckets, another for encryption configs), test files fail when they contain 5+ buckets. + +**Workaround 1:** Use single-policy approach (no limit observed) +```hcl +# ✅ One policy evaluates buckets, looks up configs +resource_policy "aws_s3_bucket" "require_encryption" { + # Can test 6+ buckets in single file +} +``` + +**Workaround 2:** Split tests across multiple files (max 4 buckets each) +``` +tests/ +├── test-scenario-1.policytest.hcl # 4 buckets +├── test-scenario-2.policytest.hcl # 4 buckets +└── test-scenario-3.policytest.hcl # 4 buckets +``` + +### Common Test Mistakes + +**Mistake:** Wrong resource has `skip` or `expect_failure` +```hcl +# ❌ WRONG - Policy evaluates buckets but test skips them +resource "aws_s3_bucket" "test" { + skip = true # Policy can't evaluate this! +} +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + expect_failure = true # Policy doesn't evaluate this! +} + +# ✅ CORRECT - Match policy evaluation target +resource "aws_s3_bucket" "test" { + expect_failure = true # Policy evaluates buckets +} +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + skip = true # Policy looks this up via core::getresources() +} +``` + +**Mistake:** Using objects instead of arrays +```hcl +# ❌ WRONG +attrs = { + rule = { key = "value" } # Object +} + +# ✅ CORRECT +attrs = { + rule = [{ key = "value" }] # Array +} +``` + +**Reason:** Terraform resources use arrays. Policies access `attrs.rule[0]`. + +--- + +## Known Policytest Framework Limitations + +These are behaviors where `tfpolicy test` passes silently but a real `terraform plan --policies=` run fails or behaves differently. Always verify port-range and integer-arithmetic policies against a real plan. + +### `core::range()` with dynamic integer attributes returns empty in policytest + +`core::range(start, end)` works correctly when called with hardcoded integer literals. However, when `start` or `end` come from mocked `attrs.*` integer values (e.g. `attrs.from_port`, `attrs.to_port`), the policytest framework treats those values as unknown/unevaluated at test time and `core::range()` silently returns an empty list `[]`. + +**Impact:** A policy that uses `core::range()` with dynamic port attributes will appear to pass all tests — including `expect_failure` cases — because the range is always empty. The bug only surfaces against a real plan. + +> ⚠️ **`core::alltrue()` and `core::anytrue()` do NOT exist in tfpolicy runtime.** Using them will produce `Error: Call to unknown function / There is no function named "alltrue" in namespace core::.`. The examples below show the **problem pattern** (❌) and the **correct alternative** (✅). + +```hcl +# ❌ WRONG — core::range() + core::alltrue() — both problematic +locals { + ports_in_range = core::range(core::try(attrs.from_port, 0), core::try(attrs.to_port, 0) + 1) + # ↑ returns [] in policytest because attrs.from_port/to_port are unknown at test time + all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)]) + # ↑ core::alltrue does NOT exist — will error; also core::range() returns [] here +} +``` + +**Fix:** Use the count approach instead — it works correctly with dynamic `attrs.*` values in both policytest and real plans: +```hcl +# ✅ Count approach — consistent in policytest and real plan evaluation +locals { + authorized_ports = [80, 443] + from_port = core::try(attrs.from_port, 0) + to_port = core::try(attrs.to_port, 0) + authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port] + all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1) +} +``` + +See `verified-syntax.md` Mistake 23 for the full pattern. + +### `core::getresources()` sees ALL resources in the test file — isolate conflicting scenarios into separate files + +In `tfpolicy test`, when a policy calls `core::getresources("some_type", filter)`, the lookup searches **all mock resources of that type in the entire test file** — including resources marked `expect_failure = true` and resources marked `skip = true`. + +**Impact:** A test scenario that requires `core::getresources()` to return zero results (or no compliant results) will silently produce the wrong outcome if any other scenario in the same file defines a resource of that type that satisfies the filter. + +```hcl +# ❌ PROBLEMATIC — both scenarios in the same test file +# "fail_no_defaults" incorrectly passes because core::getresources() picks up +# the compliant_defaults resource from the other scenario. + +resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" { + skip = true # skip = true is still visible to core::getresources()! + attrs = { http_tokens = "required" } +} + +resource "aws_instance" "pass_with_defaults" { + attrs = { instance_type = "t3.micro" } +} + +resource "aws_instance" "fail_no_defaults" { + expect_failure = true + attrs = { instance_type = "t3.micro" } + # WRONG: core::getresources("aws_ec2_instance_metadata_defaults", ...) still sees + # "compliant_defaults" above → policy evaluates as compliant → expect_failure passes incorrectly. +} +``` + +**Fix:** Place scenarios with conflicting `core::getresources()` context into separate `.policytest.hcl` files. Each file is an independent resource graph. + +```hcl +# ✅ File 1: test-with-compliant-defaults.policytest.hcl +resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" { + skip = true + attrs = { http_tokens = "required" } +} +resource "aws_instance" "pass_with_defaults" { + attrs = { instance_type = "t3.micro" } +} + +# ✅ File 2: test-no-defaults.policytest.hcl +# No aws_ec2_instance_metadata_defaults defined — core::getresources() returns empty list. +resource "aws_instance" "fail_no_defaults" { + expect_failure = true + attrs = { instance_type = "t3.micro" } +} +``` + +**Rule:** Whenever a test scenario relies on `core::getresources()` returning zero results (or no compliant results) for a given type, that scenario must be in its own `.policytest.hcl` file, completely isolated from any scenario that defines resources of that same type. + +--- + +## Related + +- [Verified Syntax Reference](verified-syntax.md) | [tfpolicy-author skill](tfpolicy-author.md) | [tfpolicy-test skill](tfpolicy-test.md) + +--- + + +## Table of Contents + +1. [Testing Basics](#testing-basics) +2. [Resource Policy Testing](#resource-policy-testing) +3. [Module Policy Testing](#module-policy-testing) +4. [Provider Policy Testing](#provider-policy-testing) +5. [Advanced Techniques](#advanced-techniques) +6. [Best Practices](#best-practices) + +--- + +## Testing Basics + +### Test File Structure + +```hcl +# Optional: specify which policy files to test +policytest { + targets = ["policy-file-1.policy.hcl", "policy-file-2.policy.hcl"] +} + +# Mock resources with expected outcomes +resource "aws_s3_bucket" "passing_bucket" { + attrs = { + bucket = "my-secure-bucket" + versioning = [{ enabled = true }] + } +} + +resource "aws_s3_bucket" "failing_bucket" { + expect_failure = true + attrs = { + bucket = "my-insecure-bucket" + versioning = [{ enabled = false }] + } +} +``` + +**Full test file structure with all supported blocks:** +```hcl +policytest { + targets = ["policy-file.policy.hcl"] +} + +# Override input defaults for this test file +inputs { + port = 90 +} + +resource "aws_security_group_rule" "should_fail_port_90" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +} +``` + +**Key Points:** +- **Best practice: include `policytest { targets = [".policy.hcl"] }` when multiple policies exist in the same directory.** Without it, `tfpolicy test` evaluates the mock against every policy in the directory — a test resource intended for one policy will silently run against others, producing misleading results. `expect_failure = true` on such a resource can pass for the wrong reason. +- `expect_failure = true` applies to ALL policies evaluating the resource +- **`expect_failure` is ONLY valid on `resource {}` blocks** — using it on `data {}` blocks causes `Unsupported argument` error + +> **⚠️ Critical: `tfpolicy test` does NOT evaluate `error_message`** +> +> `tfpolicy test` only evaluates the `condition` expression. It **never** evaluates or interpolates the `error_message` string. This means: +> - A policy with `error_message = "Failed: ${meta.address}"` will pass all `tfpolicy test` runs even though `meta.address` is UNDEFINED and will crash every resource at runtime. +> - **Only `terraform plan --policies=` evaluates `error_message`** — always validate generated policies against a real Terraform plan to catch this class of bug. +- **CRITICAL:** Omitted attributes cause evaluation errors if accessed directly. Always use `core::try()` to handle missing attributes + +### Test Execution Behavior + +1. **Multiple policies evaluate same resources** - All matching policies run against all matching test resources +2. **Tests continue on failure** - All tests run to completion, not stopping at first failure +3. **Exit codes for CI/CD**: + - Exit 0: All tests pass (including expected failures) + - Exit 1: Unexpected failures or errors + +**CI/CD Usage:** +```bash +tfpolicy test --policies=./policies --tests=./tests +if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; fi +``` + +--- + +## Resource Policy Testing + +### Basic Syntax + +```hcl +resource "resource_type" "test_name" { + expect_failure = true/false # Optional — ONLY valid on resource blocks, NOT data blocks + skip = true/false # Optional + attrs = { + # All resource attributes + } +} +``` + +> **⚠️ `expect_failure` is NOT supported on `data {}` blocks.** Using it on a data block causes `Unsupported argument "expect_failure"`. Only mock `resource {}` blocks support this attribute. + +### Omitted Attributes Behavior + +**CRITICAL:** Attributes accessed by policies MUST be provided in test mocks or wrapped with `core::try()`. + +**Direct Access (Causes Error):** +```hcl +# Policy +resource_policy "aws_ebs_volume" "check" { + enforce { + condition = attrs.encrypted == true # Direct access + } +} + +# Test - ERROR if encrypted is omitted +resource "aws_ebs_volume" "test" { + attrs = { + size = 100 + # encrypted omitted - causes "This object does not have an attribute named 'encrypted'" + } +} +``` + +**Safe Access with core::try():** +```hcl +# Policy +resource_policy "aws_ebs_volume" "check" { + locals { + encrypted = core::try(attrs.encrypted, false) # Safe access + } + enforce { + condition = local.encrypted == true + } +} + +# Test - Works even if encrypted is omitted +resource "aws_ebs_volume" "test" { + attrs = { + size = 100 + # encrypted omitted - core::try() returns false (default value) + } +} +``` + +**Rule:** Only attributes NOT accessed by the policy can be safely omitted. All accessed attributes must either: +1. Be provided in the mock's `attrs = {}` block, OR +2. Be accessed via `core::try()` in the policy + +### Testing Operation-Aware Policies + +Policies can scope themselves to specific plan operations via `operations = ["create", "update", "delete"]`. Test mocks support a matching `prior_attrs = { ... }` block alongside `attrs = { ... }`, so create, update, and delete-gate policies are all fully testable. + +**Mock shape per operation:** + +| Operation being tested | Provide `attrs` | Provide `prior_attrs` | +| --- | --- | --- | +| `create` | ✅ planned values | — | +| `update` | ✅ planned values | ✅ pre-change values | +| `delete` | — | ✅ pre-change values | + +**Create / update policy (planned values only):** + +```hcl +# Policy +resource_policy "tfe_workspace" "require_project" { + operations = ["create", "update"] + enforce { + condition = core::try(attrs.project_id, "") != "" + error_message = "tfe_workspace must have project_id set." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-require-project.policy.hcl"] } + +resource "tfe_workspace" "with_project" { + attrs = { project_id = "prj-123" } +} + +resource "tfe_workspace" "missing_project" { + expect_failure = true + attrs = {} +} +``` + +**Update policy (reads both `attrs` and `prior_attrs`):** + +```hcl +# Policy — block downgrades +resource_policy "tfe_workspace" "no_downgrade" { + operations = ["update"] + enforce { + condition = core::try(attrs.terraform_version, "") == core::try(prior_attrs.terraform_version, "") + || core::try(attrs.terraform_version, "") > core::try(prior_attrs.terraform_version, "") + error_message = "terraform_version downgrade is not allowed." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-no-downgrade.policy.hcl"] } + +resource "tfe_workspace" "upgrade_ok" { + attrs = { terraform_version = "1.10.0" } + prior_attrs = { terraform_version = "1.9.0" } +} + +resource "tfe_workspace" "downgrade_blocked" { + expect_failure = true + attrs = { terraform_version = "1.5.0" } + prior_attrs = { terraform_version = "1.9.0" } +} +``` + +**Delete-gate policy (pre-change state only):** + +```hcl +# Policy +resource_policy "tfe_workspace" "deny_delete_without_tag" { + operations = ["delete"] + locals { + prior_tag_names = core::try(prior_attrs.tag_names, []) + } + enforce { + condition = core::contains(local.prior_tag_names, "delete") + error_message = "Add 'delete' tag before destroying a workspace." + } +} +``` + +```hcl +# Test +policytest { targets = ["workspace-deny-delete-without-tag.policy.hcl"] } + +resource "tfe_workspace" "has_delete_tag" { + prior_attrs = { tag_names = ["delete", "prod"] } +} + +resource "tfe_workspace" "missing_delete_tag" { + expect_failure = true + prior_attrs = { tag_names = ["prod"] } +} +``` + +> **Note:** The runner currently evaluates each mock against every policy listed in `targets` regardless of the policy's `operations` scope. Keep your `.policytest.hcl` file targeted at a single policy (or a set of policies that share the same operation scope), and only supply the `attrs` / `prior_attrs` fields that policy actually reads. + +### Provider Schema Awareness + +The structure of `attrs = {}` depends on provider schema. Consult provider docs to determine if attributes are blocks or direct values. + +**Example - Blocks vs Attributes:** +```hcl +resource "aws_instance" "test" { + attrs = { + instance_type = "t2.micro" # Direct attribute + + # Block (requires array of maps) + default_tags = [{ + tags = { + Environment = "Production" + } + }] + } +} +``` + +**In test files:** Use `=` for blocks (not `{}` syntax used in policy files) + +### Cross-Resource References + +Reference other test resources within the SAME file using `resource_type.name.attrs.attribute`: + +```hcl +resource "aws_security_group" "app_sg" { + attrs = { + name = "app-security-group" + } +} + +resource "aws_instance" "app_server" { + attrs = { + vpc_security_group_ids = [aws_security_group.app_sg.attrs.name] + } +} +``` + +**Limitation:** References cannot span across test files. + +### The skip Attribute + +Resources with `skip = true`: +- Are added to resource graph +- Are NOT evaluated by policies +- CAN be referenced by other resources +- ARE included in `core::getresources()` results + +```hcl +resource "aws_ebs_volume" "available_for_reference" { + skip = true + attrs = { + volume_id = "vol-12345" + } +} + +resource "aws_instance" "server" { + attrs = { + ebs_block_device = [{ + volume_id = aws_ebs_volume.available_for_reference.attrs.volume_id + }] + } +} +``` + +**Use skip only when:** Resource is referenced or needed in getresources() counts. + +### Testing Filters + +If a resource doesn't match the filter, it's NOT evaluated (test passes): + +```hcl +# Policy with filter +resource_policy "aws_s3_bucket" { + filter = attrs.bucket_prefix == "secure-" + enforce { + condition = attrs.versioning[0].enabled == true + } +} + +# Test - doesn't match filter, so passes +resource "aws_s3_bucket" "filtered_out" { + attrs = { + bucket_prefix = "public-" # Doesn't match filter + versioning = [{ enabled = false }] + } +} +``` + +**Best Practice:** Test both resources that match and don't match the filter. + +### Resource Policy Meta Attributes + +**IMPORTANT:** Meta attributes for `resource_policy` behave differently in mock tests vs real terraform plan evaluation. + +**Available Meta Attributes by Evaluation Mode:** + +| Meta Attribute | Mock Tests (`tfpolicy test`) | Real Plans (`terraform plan --policies=`) | +|----------------|------------------------------|------------------------------------------| +| `meta.provider_type` | ❌ UNDEFINED | ✅ Available (e.g., "aws", "azurerm") | +| `meta.type` | ❌ UNDEFINED | ❌ UNDEFINED | +| `meta.address` | ❌ UNDEFINED | ❌ UNDEFINED | + +**Example:** +```hcl +# Policy using meta.provider_type +resource_policy "aws_ebs_volume" "check_provider" { + enforce { + condition = core::try(meta.provider_type, "UNDEFINED") == "aws" + error_message = "Provider type: ${core::try(meta.provider_type, "UNDEFINED")}" + } +} +``` + +**Test behavior:** +- With `tfpolicy test`: `meta.provider_type` returns UNDEFINED (test may fail) +- With `terraform plan --policies=`: `meta.provider_type` returns "aws" (test passes) + +**Best Practice:** When using `meta.provider_type` in policies, always wrap with `core::try()` and note that mock tests cannot fully validate this behavior. Test with real terraform plans for complete validation. + +--- + +## Module Policy Testing + +### Module Test Syntax + +```hcl +module "source" "test_name" { + expect_failure = true/false # Optional + meta = { + source = "registry.terraform.io/namespace/name" + address = "module.name" + version = "1.0.0" + } +} +``` + +**Available meta attributes:** +- `source` - Module source +- `address` - Module address (e.g., "module.database") +- `version` - Module version + +**Note:** Modules use `meta` only (no `attrs`) + +### Example: Module Source Allowlist + +**Policy:** +```hcl +locals { + allowed_sources = [ + "registry.terraform.io/hashicorp/aws", + "registry.terraform.io/terraform-aws-modules/vpc/aws" + ] +} + +module_policy "*" "approved_sources" { + filter = meta.source != null + enforce { + condition = core::contains(local.allowed_sources, meta.source) + error_message = "Unauthorized module source: ${meta.source}" + } +} +``` + +**Test:** +```hcl +# Passing +module "registry.terraform.io/hashicorp/aws" "approved" { + meta = { + source = "registry.terraform.io/hashicorp/aws" + address = "module.database" + version = "1.0.0" + } +} + +# Failing +module "registry.terraform.io/acme-corp/database" "unauthorized" { + expect_failure = true + meta = { + source = "registry.terraform.io/acme-corp/database" + address = "module.db" + version = "2.0.0" + } +} +``` + +### Example: Module Version Enforcement + +**Policy:** +```hcl +module_policy "registry.terraform.io/hashicorp/aws" "version_check" { + filter = meta.source == "registry.terraform.io/hashicorp/aws" + enforce { + condition = core::semverconstraint(meta.version, ">= 4.0.0") + error_message = "Module must use version >= 4.0.0, found ${meta.version}" + } +} +``` + +--- + +## Provider Policy Testing + +### Provider Test Syntax + +```hcl +provider "type" "test_name" { + expect_failure = true/false # Optional + meta = { + source = "registry.terraform.io/namespace/name" + } +} +``` + +**Available meta attributes:** +- `source` - Provider source (e.g., "registry.terraform.io/hashicorp/aws") + +**Note:** Provider type (e.g., "aws") goes in block declaration, not meta. + +### Example: Provider Source Allowlist + +**Policy:** +```hcl +locals { + allowed_provider_sources = [ + "registry.terraform.io/hashicorp/aws", + "registry.terraform.io/hashicorp/azurerm" + ] +} + +provider_policy "aws" { + enforce { + condition = core::contains(local.allowed_provider_sources, meta.source) + error_message = "Provider source '${meta.source}' is not approved" + } +} +``` + +**Test:** +```hcl +# Passing +provider "aws" "official" { + meta = { + source = "registry.terraform.io/hashicorp/aws" + } +} + +# Failing +provider "aws" "unofficial" { + expect_failure = true + meta = { + source = "registry.terraform.io/acme-corp/aws" + } +} +``` + +### Common Provider Patterns + +1. **Official providers only**: Check `meta.source == "registry.terraform.io/hashicorp/aws"` +2. **Version constraints**: Use `core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0")` +3. **Allowlist by type**: Create separate provider_policy for each allowed type + +--- + +## Advanced Techniques + +### Data Source Mocking + +```hcl +data "aws_ami" "ubuntu" { + attrs = { + id = "ami-12345" + name = "ubuntu-20.04" + } +} + +# Policy can reference it +resource_policy "aws_instance" { + enforce { + condition = attrs.ami == data.aws_ami.ubuntu.attrs.id + } +} +``` + +### Workspace Context Limitations + +❌ **Not Available:** `terraform.workspace` or workspace context + +**Valid traversal roots:** +- `input` - Input variables +- `local` - Local variables +- `attrs` - Resource/data source attributes +- `meta` - Metadata + +**Workarounds:** +1. Use resource tags for environment-based logic +2. Separate policy sets per environment in HCP Terraform +3. CI/CD-level enforcement based on workspace name +4. Tag-based validation + +### Testing Collections + +```hcl +resource "aws_security_group" "multiple_rules" { + attrs = { + ingress = [ + { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + }, + { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + ] + } +} +``` + +### Testing Null/Missing Attributes + +**Only works if policy uses `core::try()`:** + +```hcl +# Policy must use core::try() to handle missing attributes +resource_policy "aws_s3_bucket" "check" { + locals { + encryption = core::try(attrs.server_side_encryption_configuration, null) + } + enforce { + condition = local.encryption != null + error_message = "Encryption required" + } +} + +# Test - omitted attribute handled by core::try() +resource "aws_s3_bucket" "no_encryption" { + expect_failure = true + attrs = { + bucket = "my-bucket" + # server_side_encryption_configuration omitted - handled by core::try() + } +} +``` + +**Without `core::try()`, omitting accessed attributes causes evaluation errors.** + +--- + +### Testing Policies with `input` Blocks + +Policies that use `input` blocks can have their input values overridden per test file using an **`inputs {}`** block (plural). This allows you to test the policy behaviour under different configurations without changing the policy itself. + +> ⚠️ The block is `inputs {}` (plural) — using `input {}` (singular) throws `Unsupported block type` error. + +```hcl +# Policy (test.policy.hcl) +input "port" { + type = number + default = 22 +} + +resource_policy "aws_security_group_rule" "no_open_ingress" { + filter = core::try(attrs.type, "") == "ingress" + locals { + covers_port = core::try(attrs.from_port <= input.port && attrs.to_port >= input.port, false) + } + enforce { + condition = !local.covers_port + error_message = "Ingress rule covers restricted port ${input.port}." + } +} +``` + +```hcl +# Test file 1: test with default port (22) +policytest { + targets = ["test.policy.hcl"] +} +# No inputs block — uses input.port default = 22 + +# PASS: port range 80-443 does not cover default port 22 +resource "aws_security_group_rule" "pass_default_port" { + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + } +} + +# FAIL: port range 1-1024 covers default port 22 +resource "aws_security_group_rule" "fail_default_port" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 1 + to_port = 1024 + protocol = "tcp" + } +} +``` + +```hcl +# Test file 2: test with custom port (90) +policytest { + targets = ["test.policy.hcl"] +} + +inputs { + port = 90 # override default of 22 +} + +# FAIL: port range 80-443 DOES cover custom port 90 +resource "aws_security_group_rule" "fail_custom_port" { + expect_failure = true + attrs = { + type = "ingress" + from_port = 80 + to_port = 443 + protocol = "tcp" + } +} +``` + +**Key points:** +- Each test file can have its own `inputs {}` block with different values — use separate `.policytest.hcl` files per input scenario +- When no `inputs {}` block is present, the policy's `default` values are used +- Always add a comment to each test file stating which input values it assumes — prevents confusion when the same resource mock produces different results under different inputs +- **Note:** input values can only be overridden at policy-set level in HCP Terraform for live enforcement — `inputs {}` in test files is for test-time validation only + +Every generated `.policytest.hcl` **must** include test cases for the following scenarios. Missing any of these is a test coverage gap: + +| Scenario | Mock pattern | Why it matters | +|----------|-------------|----------------| +| **Missing attribute** (omitted entirely) | `attrs = { bucket = "x" }` — target attribute not present | Crashes policies that don't use `core::try()`; most common real-world gap | +| **Null attribute** | `attrs = { ..., field = null }` | Tests `core::try()` default handling | +| **Empty list** | `attrs = { ..., items = [] }` | Policies expecting non-empty collections must handle `[]` | +| **Empty string** | `attrs = { ..., value = "" }` | String-check policies must not treat `""` as compliant | +| **Boundary value** | Exact threshold (e.g. port = 443, count = max_allowed) | Off-by-one errors in range/count checks | + +```hcl +# ✅ Missing attribute — must fail (tests core::try() default) +resource "aws_s3_bucket" "missing_encryption" { + expect_failure = true + attrs = { + bucket = "test-bucket" + # server_side_encryption_configuration intentionally omitted + } +} + +# ✅ Empty list — must fail +resource "aws_s3_bucket" "empty_encryption_rules" { + expect_failure = true + attrs = { + bucket = "test-bucket" + server_side_encryption_configuration = [] + } +} +``` + +--- + +## Best Practices + +### General Guidelines + +1. **Use descriptive names**: `encrypted_volume_passes` not `test1` +2. **Organize by scenario**: Group passing/failing tests with comments +3. **Test edge cases**: Always include missing-attribute, null, empty-collection, and boundary-value scenarios — see [Mandatory Edge-Case Checklist](#mandatory-edge-case-checklist) above +4. **Consult provider schemas**: Match provider's block/attribute structure + +### Testing Strategy + +1. **Separate concerns**: One test file per policy file +2. **Use skip strategically**: Only when resource is referenced or in getresources() counts +3. **Test both sides of filters**: Resources that match and don't match +4. **Document complex references**: Add comments explaining relationships + +### File Organization + +``` +policies/ +├── cis-4.1-deny-public-ssh.policy.hcl +├── cis-4.1-deny-public-ssh.policytest.hcl +├── cis-4.2-deny-public-rdp.policy.hcl +└── cis-4.2-deny-public-rdp.policytest.hcl +``` + +--- + +## Quick Reference Table + +| Policy Type | Test Block | Available Attributes | +|-------------|------------|---------------------| +| resource_policy | `resource "type" "name" { attrs = {...} }` | `attrs.*`, `meta.provider_type` (real plans only) | +| module_policy | `module "source" "name" { meta = {...} }` | `meta.source`, `meta.address`, `meta.version` | +| provider_policy | `provider "type" "name" { meta = {...} }` | `meta.source` | +| data source | `data "type" "name" { attrs = {...} }` | `attrs.*` | + +### Common Features + +| Feature | Syntax | Scope | +|---------|--------|-------| +| Test target | `policytest { targets = ["file.policy.hcl"] }` | Optional (best practice when multiple policies in dir) | +| Override input values | `inputs { key = value }` | Per test file — uses policy `default` if omitted | +| Expected failure | `expect_failure = true` | All policies | +| Skip evaluation | `skip = true` | Resources only | +| Cross-resource ref | `resource_type.name.attrs.attribute` | Same file only | +| Omitted attributes | Don't specify in `attrs = {}` | Causes error unless policy uses `core::try()` | + +--- + +## Advanced Testing Patterns (Real-World Learnings) + +### Cross-Resource Lookup Pattern + +**Problem:** Need to enforce that every S3 bucket has a corresponding encryption configuration. + +**Solution:** Evaluate buckets, look up encryption configs via `core::getresources()`. + +```hcl +# Top-level: Get all encryption configs once +locals { + all_encryption_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {}) +} + +# Resource-level: Find matching config for each bucket +resource_policy "aws_s3_bucket" "require_encryption" { + locals { + matching_configs = [ + for config in local.all_encryption_configs : + config if config.bucket == attrs.bucket + ] + } + enforce { + condition = core::try(local.matching_configs[0], null) != null + error_message = "Bucket must have encryption config" + } +} +``` + +**Test Structure:** +```hcl +# Evaluated resource - NO skip +resource "aws_s3_bucket" "test" { + attrs = { bucket = "test" } +} + +# Looked-up resource - YES skip (but still visible to core::getresources) +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + skip = true + attrs = { + bucket = aws_s3_bucket.test.bucket + rule = [{ ... }] # Must be array! + } +} +``` + +**Key Points:** +- ✅ Resources with `skip = true` ARE visible to `core::getresources()` +- ✅ Use top-level `locals` for `core::getresources()` (performance) +- ✅ Always evaluate the resource that must exist, look up optional ones + +### The Two-Check Pattern + +**Problem:** Need to check two related attributes to determine compliance. + +**Example:** S3 must use customer-managed KMS keys (not AWS-managed or AES256). + +AWS encryption types: +- SSE-S3 (AES256) - S3-managed keys ❌ +- SSE-KMS without key ID - AWS-managed "aws/s3" key ❌ +- SSE-KMS with key ID - Customer-managed key ✅ + +**Why one check fails:** +```hcl +# ❌ Only checks algorithm +condition = attrs.sse_algorithm == "aws:kms" +# PASSES even without kms_master_key_id (uses AWS-managed key!) + +# ❌ Only checks key ID +condition = attrs.kms_master_key_id != "" +# PASSES even with "AES256" algorithm (not using KMS!) +``` + +**Correct: Check both** +```hcl +locals { + sse_algorithm = core::try(attrs.encryption[0].sse_algorithm, "") + kms_key_id = core::try(attrs.encryption[0].kms_master_key_id, "") +} +enforce { + condition = local.sse_algorithm == "aws:kms" && local.kms_key_id != "" + error_message = "Must use customer-managed KMS. Found algorithm: '${local.sse_algorithm}', key specified: ${local.kms_key_id != ""}" +} +``` + +### Test File Size Limitation + +**Discovery:** With two-policy approach (one policy for buckets, another for encryption configs), test files fail when they contain 5+ buckets. + +**Workaround 1:** Use single-policy approach (no limit observed) +```hcl +# ✅ One policy evaluates buckets, looks up configs +resource_policy "aws_s3_bucket" "require_encryption" { + # Can test 6+ buckets in single file +} +``` + +**Workaround 2:** Split tests across multiple files (max 4 buckets each) +``` +tests/ +├── test-scenario-1.policytest.hcl # 4 buckets +├── test-scenario-2.policytest.hcl # 4 buckets +└── test-scenario-3.policytest.hcl # 4 buckets +``` + +### Common Test Mistakes + +**Mistake:** Wrong resource has `skip` or `expect_failure` +```hcl +# ❌ WRONG - Policy evaluates buckets but test skips them +resource "aws_s3_bucket" "test" { + skip = true # Policy can't evaluate this! +} +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + expect_failure = true # Policy doesn't evaluate this! +} + +# ✅ CORRECT - Match policy evaluation target +resource "aws_s3_bucket" "test" { + expect_failure = true # Policy evaluates buckets +} +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + skip = true # Policy looks this up via core::getresources() +} +``` + +**Mistake:** Using objects instead of arrays +```hcl +# ❌ WRONG +attrs = { + rule = { key = "value" } # Object +} + +# ✅ CORRECT +attrs = { + rule = [{ key = "value" }] # Array +} +``` + +**Reason:** Terraform resources use arrays. Policies access `attrs.rule[0]`. + +--- + +## Known Policytest Framework Limitations + +These are behaviors where `tfpolicy test` passes silently but a real `terraform plan --policies=` run fails or behaves differently. Always verify port-range and integer-arithmetic policies against a real plan. + +### `core::range()` with dynamic integer attributes returns empty in policytest + +`core::range(start, end)` works correctly when called with hardcoded integer literals. However, when `start` or `end` come from mocked `attrs.*` integer values (e.g. `attrs.from_port`, `attrs.to_port`), the policytest framework treats those values as unknown/unevaluated at test time and `core::range()` silently returns an empty list `[]`. + +**Impact:** A policy that uses `core::range()` with dynamic port attributes will appear to pass all tests — including `expect_failure` cases — because the range is always empty. The bug only surfaces against a real plan. + +> ⚠️ **`core::alltrue()` and `core::anytrue()` do NOT exist in tfpolicy runtime.** Using them will produce `Error: Call to unknown function / There is no function named "alltrue" in namespace core::.`. The examples below show the **problem pattern** (❌) and the **correct alternative** (✅). + +```hcl +# ❌ WRONG — core::range() + core::alltrue() — both problematic +locals { + ports_in_range = core::range(core::try(attrs.from_port, 0), core::try(attrs.to_port, 0) + 1) + # ↑ returns [] in policytest because attrs.from_port/to_port are unknown at test time + all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)]) + # ↑ core::alltrue does NOT exist — will error; also core::range() returns [] here +} +``` + +**Fix:** Use the count approach instead — it works correctly with dynamic `attrs.*` values in both policytest and real plans: +```hcl +# ✅ Count approach — consistent in policytest and real plan evaluation +locals { + authorized_ports = [80, 443] + from_port = core::try(attrs.from_port, 0) + to_port = core::try(attrs.to_port, 0) + authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port] + all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1) +} +``` + +See `verified-syntax.md` Mistake 23 for the full pattern. + +### `core::getresources()` sees ALL resources in the test file — isolate conflicting scenarios into separate files + +In `tfpolicy test`, when a policy calls `core::getresources("some_type", filter)`, the lookup searches **all mock resources of that type in the entire test file** — including resources marked `expect_failure = true` and resources marked `skip = true`. + +**Impact:** A test scenario that requires `core::getresources()` to return zero results (or no compliant results) will silently produce the wrong outcome if any other scenario in the same file defines a resource of that type that satisfies the filter. + +```hcl +# ❌ PROBLEMATIC — both scenarios in the same test file +# "fail_no_defaults" incorrectly passes because core::getresources() picks up +# the compliant_defaults resource from the other scenario. + +resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" { + skip = true # skip = true is still visible to core::getresources()! + attrs = { http_tokens = "required" } +} + +resource "aws_instance" "pass_with_defaults" { + attrs = { instance_type = "t3.micro" } +} + +resource "aws_instance" "fail_no_defaults" { + expect_failure = true + attrs = { instance_type = "t3.micro" } + # WRONG: core::getresources("aws_ec2_instance_metadata_defaults", ...) still sees + # "compliant_defaults" above → policy evaluates as compliant → expect_failure passes incorrectly. +} +``` + +**Fix:** Place scenarios with conflicting `core::getresources()` context into separate `.policytest.hcl` files. Each file is an independent resource graph. + +```hcl +# ✅ File 1: test-with-compliant-defaults.policytest.hcl +resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" { + skip = true + attrs = { http_tokens = "required" } +} +resource "aws_instance" "pass_with_defaults" { + attrs = { instance_type = "t3.micro" } +} + +# ✅ File 2: test-no-defaults.policytest.hcl +# No aws_ec2_instance_metadata_defaults defined — core::getresources() returns empty list. +resource "aws_instance" "fail_no_defaults" { + expect_failure = true + attrs = { instance_type = "t3.micro" } +} +``` + +**Rule:** Whenever a test scenario relies on `core::getresources()` returning zero results (or no compliant results) for a given type, that scenario must be in its own `.policytest.hcl` file, completely isolated from any scenario that defines resources of that same type. + +--- + +## Related + +- [Verified Syntax Reference](verified-syntax.md) | [tfpolicy-author skill](tfpolicy-author.md) | [tfpolicy-test skill](tfpolicy-test.md) diff --git a/.agents/skills/terraform-policy/references/verified-syntax.md b/.agents/skills/terraform-policy/references/verified-syntax.md new file mode 100644 index 0000000..3397141 --- /dev/null +++ b/.agents/skills/terraform-policy/references/verified-syntax.md @@ -0,0 +1,2024 @@ +# Terraform Policy - Verified Syntax Reference + +> **Shared reference** used by all sibling skills in `references/`: +> [tfpolicy-author](tfpolicy-author.md) | [tfpolicy-test](tfpolicy-test.md) + +**Last Updated:** 2026-02-24 +**Status:** All patterns user-verified during private beta +**Purpose:** Source-of-truth quick reference. Sub-skills link here rather than duplicating facts. + +--- + +## Critical Rules + +### 1. ✅ Semantic Versioning (VERIFIED) +**Rule:** Use `core::semverconstraint()` for ALL version comparisons +```hcl +# ✅ Correct +condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0") + +# ❌ Wrong +condition = meta.version >= 4.0 # Don't use direct comparison +``` + +**Constraint syntax:** +- `"= 4.67.0"` - Exact version +- `">= 4.0.0"` - Minimum +- `"< 5.0.0"` - Maximum +- `"~> 4.67.0"` - Pessimistic patch (>= 4.67.0, < 4.68.0) +- `"~> 4.0"` - Pessimistic minor (>= 4.0.0, < 5.0.0) +- `">= 4.0.0, < 5.0.0"` - Multiple constraints (AND) +- `"!= 4.50.0"` - Exclude version + +--- + +### 2. ✅ Core Function Prefix (VERIFIED) +**Rule:** ALL built-in Terraform functions require `core::` prefix + +```hcl +# ✅ Correct +filter = core::try(attrs.encrypted, false) == true +is_allowed = core::length([for v in local.version_checks : v if v]) > 0 +error_message = "Allowed: ${core::join(", ", local.allowed_versions)}" + +# ❌ Wrong +filter = try(attrs.encrypted, false) == true # Missing core:: prefix +``` + +**Common functions:** +- `core::try(expr, default)` - Safe access with fallback +- `core::contains(list, value)` - List membership (**lists only, NOT strings!**) +- `core::length(list_or_string)` - List/string/map length (✅ works on all!) +- `core::keys(map)` - Get map keys as list (✅ requires core:: prefix) +- `core::join(separator, list)` - Join list elements +- `core::semverconstraint(version, constraint)` - Version comparison +- `core::getresources(type, filter_map)` - Query related resources +- ❌ ~~`core::anytrue(list)`~~ — **DOES NOT EXIST** in tfpolicy runtime. Use `core::length([for b in list : b if b]) > 0` instead. +- ❌ ~~`core::alltrue(list)`~~ — **DOES NOT EXIST** in tfpolicy runtime. Use `core::length([for b in list : b if !b]) == 0` instead. + +**⚠️ IMPORTANT: `core::getresources()` filter behavior with unknown attribute values.** +- The `filter_map` argument is **required by the function signature** (omitting it → "Not enough function arguments"). Passing `{}` matches everything; passing `{ attr = value }` performs equality matching. +- **Caveat:** when the target attribute on a candidate resource is **unknown at plan time** (e.g. `bucket = aws_s3_bucket.x.id` where `aws_s3_bucket.x` is being created in the same plan), the equality comparison evaluates to unknown and the engine **conservatively includes that candidate** in the result set. The filter is not "ignored" — but it cannot narrow results past any candidate whose target attribute is computed. +- Verified on terraform 1.15.0-policy20261105 / tfpolicy 0.0.2-beta20260513 / tfpolicy-plugin 0.0.2-beta20260422. +- **Production impact:** any cross-resource policy on a first-time-create plan (where related resources reference each other via `.id`) will see every candidate, not the matching one. Update plans against existing infrastructure (where target attributes are already known) filter correctly. +- **Pattern choice — constant filter vs. `attrs.*` filter:** + - **When the filter value is a known constant AND no secondary `attrs.*` filtering is needed inside `resource_policy`**: you MAY call `core::getresources()` once in a top-level `locals` block. A valid example is a truly account-level resource with no per-parent link: + ```hcl + # ✅ CORRECT — aws_s3_account_public_access_block is account-level; no per-bucket link + locals { + account_pab = core::getresources("aws_s3_account_public_access_block", {}) + } + resource_policy "aws_s3_bucket" "account_block_required" { + locals { + pab = core::length(local.account_pab) > 0 ? local.account_pab[0] : null + block_public_acls = local.pab != null ? core::try(local.pab.block_public_acls, false) : false + } + } + ``` + - **When the filter value comes from `attrs.*`** (e.g. `attrs.id`, `attrs.name`, `attrs.arn`) OR when any secondary filtering inside `resource_policy` is by `attrs.*`: use an **inline `core::getresources()` call with the specific per-resource filter** inside `resource_policy`. The top-level cache with a `{}` empty filter plus HCL-side `attrs.*` filtering is the **wrong pattern** — see Mistake 13 CRITICAL note below. + ```hcl + # ✅ CORRECT — filter value "table/${attrs.name}" comes from attrs.* — must be inline + resource_policy "aws_dynamodb_table" "autoscaling_required" { + locals { + table_resource_id = "table/${attrs.name}" + scaling_targets = core::getresources("aws_appautoscaling_target", { + resource_id = local.table_resource_id + }) + } + } + # ❌ WRONG — top-level {} cache + HCL filter by attrs.* is the anti-pattern + # locals { all_targets = core::getresources("aws_appautoscaling_target", {}) } + # resource_policy "aws_dynamodb_table" { locals { filtered = [for t in local.all_targets : t if t.resource_id == "table/${attrs.name}"] } } + ``` + - > ⚠️ **DynamoDB autoscaling is ALWAYS the inline pattern (Pattern B).** Even pre-filtering at the top level by a constant `scalable_dimension` does not make it Pattern A — the secondary `resource_id == "table/${attrs.name}"` filter inside `resource_policy` is still derived from `attrs.name`, so the correct approach is an inline `core::getresources()` call filtered by `resource_id`. Pre-filtering by `scalable_dimension` at the top level forces you to add the `attrs.*`-derived `resource_id` filter inside `resource_policy`, which is the anti-pattern. Use the inline call and apply the constant `scalable_dimension` check as a simple HCL filter after the inline fetch. +- ⚠️ When the filter value is derived from `attrs.*` and that attribute is **unknown at plan time** (e.g. `bucket = aws_s3_bucket.x.id` for a newly-created resource), the equality comparison evaluates to unknown — the engine conservatively includes that candidate in the result set, so first-time-create plans with cross-references are unreliable. Most reliable on updates and existing infrastructure where target values are known. +- > ⛔ **All dependent child resources — always use the inline filter pattern, NOT the top-level `{}` cache.** This applies to `aws_s3_bucket_public_access_block`, `aws_s3_bucket_acl`, `aws_s3_bucket_server_side_encryption_configuration`, `aws_s3_bucket_policy`, `aws_appautoscaling_target`, `aws_appautoscaling_policy`, and any resource type that has a `(Required)` or `(Optional)` argument referencing a parent resource. Call `core::getresources()` **inline** inside the parent `resource_policy` with the specific filter (e.g. `{bucket = attrs.id}`, `{resource_id = local.table_resource_id}`). Do NOT use the top-level cache `{}` + HCL for-loop pattern for these types. See Mistake 13. + +**CRITICAL: core::getresources() Attribute Access:** +- Resources returned have attributes at **top level** (NOT through `.attrs`) +- Example: `resource.name` ✅ NOT `resource.attrs.name` ❌ +- This is DIFFERENT from current resource context where you use `attrs.name` +- **Pattern:** For dependent child resources (any type with a `(Required)` or `(Optional)` argument referencing a parent by `.id`, `.arn`, or `.name`): use inline `core::getresources()` inside `resource_policy` with the specific per-parent filter; access returned attributes directly at top level (NOT through `.attrs`). For truly independent/account-level resources: cache in top-level `locals` with a constant filter (if any), and access returned attributes directly. + +**✅ String functions available:** +- ✅ `core::startswith(string, prefix)` - Returns bool. **Arg order: full string first, prefix second** (same as Sentinel's `strings.has_prefix`). e.g. `core::startswith(meta.version, ">")` ✅ +- ✅ `core::endswith(string, suffix)` - Returns bool +- ✅ `core::contains_substring(string, substr)` - Returns bool +- ✅ **`core::regex(pattern, string)`** - Pattern/substring matching. Throws on no match — wrap with `core::try()`: `core::try(core::regex("pattern", string), null) != null` +- ✅ **`core::split(separator, string)`** - Splits a string into a list of substrings at each occurrence of `separator`. Example: `core::split("-", "1-100")` returns `["1", "100"]`; `core::split("-", "22")` returns `["22"]`. Use with `core::parseint()` to parse port ranges like `"start-end"` without regex: + ```hcl + # ✅ Parsing a port range "start-end" using core::split (preferred over regex) + port_parts = core::split("-", local.dest_port_range) + range_start = core::length(local.port_parts) == 2 ? core::try(core::parseint(local.port_parts[0], 10), -1) : -1 + range_end = core::length(local.port_parts) == 2 ? core::try(core::parseint(local.port_parts[1], 10), -1) : -1 + # Range covers port 22 if start < 22 AND end > 22 (exclusive, matching Sentinel logic) + is_range_ssh = local.range_start < 22 && local.range_end > 22 + ``` + Note: ternary short-circuits, so `core::parseint` is only called when `core::length == 2`. When the port string is not a range (e.g. `"22"` or `"*"`), `range_start` and `range_end` default to `-1`, making the range check false without any index-out-of-bounds risk. +- ❌ Substring matching via `core::contains()` — only works for lists, NOT strings + ```hcl + # Prefix check using core::startswith() + starts_with_open = core::startswith(local.version_value, ">") + # Substring check using core::regex() + has_exception = core::try(core::regex("exception", core::try(attrs.description, "")), null) != null + ``` + +**Provider version constraint checks:** In `provider_policy`, `meta.version` is the **resolved provider version** (e.g. `"6.50.0"`), **not** the constraint string from `required_providers`. There is no tfpolicy surface that exposes the constraint string. + +> ⚠️ **`providers-require-version`-style Sentinel policies** that check `strings.has_prefix(p.version_constraint, ">")` inspect the version constraint format string, which tfpolicy does not expose. This check is **non-convertible**. The closest tfpolicy equivalent enforces that the **resolved provider version** is within an approved range: +```hcl +# Sentinel: strings.has_prefix(p.version_constraint, ">") → non-convertible +# TFPolicy nearest equivalent — enforce resolved version range instead: +provider_policy "*" "provider_version_range" { + enforce { + condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0") + error_message = "Provider version '${meta.version}' must satisfy '>= 4.0.0, < 5.0.0'. Pin the provider to a tested version range to prevent major version upgrades." + } +} +``` +> ⚠️ **Arg order:** `core::startswith(string, prefix)` — the full string is the **first** argument, the prefix is the **second**. Do not reverse them. + +**✅ JSON functions available:** +- ✅ `core::jsondecode(string)` — parses a JSON string into an HCL object/list. Use when an attribute contains a serialised JSON value (e.g. an inline IAM policy document). +- ✅ `core::jsonencode(value)` — encodes an HCL object/list as a JSON string. +- ❌ `json::unmarshal` — **does not exist**. There is no `json::` namespace. Always use `core::jsondecode` instead. + ```hcl + locals { + policy_doc = core::try(core::jsondecode(core::try(attrs.policy, "{}")), {}) + statements = core::try(local.policy_doc.Statement, []) + } + ``` + +--- + +### 3. ✅ Policy Type Features (VERIFIED) +**Rule:** ALL policy types support the same features + +| Feature | resource_policy | module_policy | provider_policy | +|---------|----------------|---------------|-----------------| +| `locals {}` | ✅ Yes | ✅ Yes | ✅ Yes | +| `filter` clause | ✅ Yes | ✅ Yes | ✅ Yes | +| Multiple `enforce` | ✅ Yes | ✅ Yes | ✅ Yes | + +**IMPORTANT:** Language server shows FALSE ERRORS for `locals` in `provider_policy` +- Error: "No declaration found for local.variable" +- **These are safe to ignore** - syntax is valid +- Runtime evaluation works correctly + +--- + +### 4. ✅ CRITICAL: Blocks vs Attributes Schema Distinction + +**Rule:** Provider schema representation determines how you access nested configuration + +**Blocks vs Attributes:** +- **BLOCKS** → Represented as **lists of maps** (even if only one allowed) + - Require array indexing: `attrs.block_name[0].field` + - Examples: `default_tags`, `assume_role`, `lifecycle` +- **ATTRIBUTES** → Represented as **direct values** (maps, strings, numbers, etc.) + - Direct access: `attrs.attribute_name` + - Examples: `region`, `tags`, `instance_type` + +**Examples:** + +```hcl +# ✅ BLOCK access (default_tags in AWS provider) +# Schema: default_tags = [ { tags = { "Env" = "Dev" } } ] +default_tags_map = core::try(attrs.default_tags[0].tags, {}) # Need [0] index + +# ✅ ATTRIBUTE access (tags on resources) +# Schema: tags = { "Env" = "Dev" } +has_tags = attrs.tags != null && core::length(attrs.tags) > 0 # Direct access, no [0] + +# Note: core::length() works directly on maps, no need for core::keys() +``` + +**How to determine Block vs Attribute:** +1. Check provider schema documentation +2. Use `terraform console` to inspect structure +3. If accessing nested field fails without `[0]`, it's a Block + +--- + +### 5. ✅ Input Blocks — Runtime Parameterization (VERIFIED) +**Rule:** Use `input` blocks instead of hardcoded `locals` for values that vary per policy set. Never claim Sentinel `param` cannot be replicated — `input` is the direct equivalent. + +```hcl +# ✅ Correct — parameterized, overridable per policy set +input "allowed_providers" { + type = list(string) + default = ["registry.terraform.io/hashicorp/aws"] +} + +provider_policy "*" "providers_allowlist" { + enforce { + condition = core::contains(input.allowed_providers, meta.source) + error_message = "Provider '${meta.source}' not allowed. Permitted: ${core::join(", ", input.allowed_providers)}." + } +} + +# ❌ Wrong — hardcoding what should be configurable +locals { + allowed_providers = ["registry.terraform.io/hashicorp/aws"] # Never override without editing policy file +} +``` + +**Supported types:** `string`, `number`, `bool`, `list(string)`, `list(number)`, `map(string)` +**Override mechanism:** `.tfpolicy.metadata.json` at policy-set scope, or per-evaluation overrides. +**Use for:** allowlists, blocklists, version constraints, limits, thresholds — any value the operator may want to tune. + +--- + +### 6. ✅ Operations Scoping + prior_attrs (VERIFIED) +**Rule:** Use `operations = [...]` to restrict when a policy fires. Use `prior_attrs` to read pre-change state on delete/update. + +```hcl +# ✅ Fires only on create and update — never on destroy +resource_policy "tfe_workspace" "require_project" { + operations = ["create", "update"] + enforce { + condition = core::try(attrs.project_id, "") != "" + error_message = "tfe_workspace must have project_id set." + } +} + +# ✅ Delete-gate: check prior state before workspace is destroyed +resource_policy "tfe_workspace" "deny_delete_without_tag" { + operations = ["delete"] # prior_attrs is available when "create" is NOT in operations + locals { + prior_tag_names = core::try(prior_attrs.tag_names, []) + had_delete_tag = core::contains(local.prior_tag_names, "delete") + } + enforce { + condition = local.had_delete_tag + error_message = "Add 'delete' tag before destroying a workspace." + } +} +``` + +**Key rules:** +- `operations = ["create", "update"]` — skips destroy; equivalent to Sentinel `rc.change.actions is not ["delete"]` +- `operations = ["delete"]` — fires only on destroy; `prior_attrs` holds the before-state +- `prior_attrs` is only available when `"create"` is NOT in `operations` +- Default (no `operations`) = fires on create and update + +--- + +### 7. ✅ Time Functions (VERIFIED) +**Rule:** `core::timestamp()`, `core::formatdate()`, and `core::parseint()` exist. Never generate placeholder policies claiming time functions are unavailable. + +```hcl +# ✅ Day-of-week restriction +input "restricted_weekdays_utc" { + type = list(string) + default = ["Friday", "Saturday", "Sunday"] +} + +resource_policy "tfe_workspace" "deny_apply_day_of_week" { + locals { + current_weekday = core::formatdate("EEEE", core::timestamp()) # "Monday", "Friday", etc. + is_restricted = core::contains(input.restricted_weekdays_utc, local.current_weekday) + } + enforce { + condition = !local.is_restricted + error_message = "Apply blocked on ${local.current_weekday} (UTC). Restricted days: ${core::join(", ", input.restricted_weekdays_utc)}." + } +} + +# ✅ Hour-of-day restriction +input "restricted_hours_utc" { + type = list(number) + default = [8, 9, 10, 11, 12] +} + +resource_policy "tfe_workspace" "deny_apply_hour_of_day" { + locals { + current_hour = core::parseint(core::formatdate("HH", core::timestamp()), 10) + is_restricted = core::contains(input.restricted_hours_utc, local.current_hour) + } + enforce { + condition = !local.is_restricted + error_message = "Apply blocked during hour ${local.current_hour} UTC." + } +} +``` + +**Available time functions:** +- `core::timestamp()` — current UTC time as RFC3339 string +- `core::formatdate(spec, timestamp)` — format specifiers: `"EEEE"` (weekday name), `"HH"` (hour 00-23), `"DD"` (day), `"MM"` (month), `"YYYY"` (year) +- `core::timeadd(timestamp, duration)` — add duration (e.g. `"24h"`, `"-1h"`) +- `core::timecmp(ts1, ts2)` — compare two timestamps +- `core::parseint(string, base)` — parse string to integer (e.g. `core::parseint("08", 10)` → `8`) +- **Note:** All times are UTC. Express restricted windows in UTC. + +--- + +### 8. ✅ Naming Conventions (VERIFIED) + +**`resource_policy` block names — use `snake_case` derived from the enforcement requirement:** + +```hcl +# ✅ Correct — descriptive snake_case +resource_policy "aws_s3_bucket" "versioning_required" { } +resource_policy "aws_iam_policy" "no_admin_privileges" { } +resource_policy "aws_ecs_task_definition" "secure_networking_mode_and_user" { } + +# ❌ Wrong — generic names that don't describe the check +resource_policy "aws_s3_bucket" "check" { } +resource_policy "aws_s3_bucket" "all_checks" { } +resource_policy "aws_s3_bucket" "policy" { } +``` + +**Rules:** +- Use `snake_case` (underscores, lowercase). Never use kebab-case (hyphens) in block names. +- Name should reflect the enforcement requirement, not the resource type (the type is already in the first argument). +- When a single resource type has multiple enforce blocks (the correct pattern), choose one name that describes the combined requirement rather than the individual checks. +- For IAM 4-type rules, use the same `policy_name` across all 4 blocks (e.g. `no_admin_privileges`) — the resource type in the first argument differentiates them. + +--- + +### 9. ✅ `core::try()` Default Type Selection (VERIFIED) + +Choose the default value based on the semantic type of the attribute — do NOT mix defaults for the same attribute across related checks: + +| Attribute type | Correct default | Rationale | +|---|---|---| +| Boolean | `false` | Absent boolean = permissive default (fails the check correctly) | +| Required string | `""` | Absent string = empty = fails non-empty checks correctly | +| List / set | `[]` | Absent list = empty = length checks correctly return 0 | +| Map / object | `{}` | Absent map = empty = key lookups return null | +| "Must detect unset separately from empty" | `null` | When `""` and `null` must be treated differently by the condition | + +```hcl +# ✅ Correct defaults by type +encrypted = core::try(attrs.encrypted, false) # boolean +bucket_name = core::try(attrs.bucket, "") # string +tag_names = core::try(attrs.tag_names, []) # list +tags = core::try(attrs.tags, {}) # map +optional_config = core::try(attrs.config, null) != null # detect presence +``` + +**⚠️ Using the wrong default silently masks violations:** +- `core::try(attrs.encrypted, true)` — absent = treated as encrypted = violation missed +- `core::try(attrs.tag_names, ["compliant"])` — absent = treated as tagged = violation missed + +--- + +## Common Mistakes to Avoid + +### ❌ Mistake 1: Direct Version Comparison +```hcl +# Wrong +condition = meta.version < 5.0 +``` +**Fix:** Use `core::semverconstraint(meta.version, "< 5.0.0")` + +### ❌ Mistake 2: Direct Attribute Access Without try() +```hcl +# Wrong - Crashes when attribute doesn't exist +has_region = attrs.region != null + +# Wrong - Still crashes even with double check! +condition = attrs.encrypted != null && attrs.encrypted == true +``` +**Fix:** Use two-step safe access pattern: +```hcl +# Correct +region_value = core::try(attrs.region, null) +has_region = local.region_value != null +``` +**Why:** Cannot test attribute existence before accessing (no `"attr" in attrs` syntax). Must use `core::try()` first. + + +### ❌ Mistake 4: Trusting Language Server Errors +```hcl +# Language server shows error, but syntax is valid: +provider_policy "aws" "example" { + locals { # ❌ False error: "No declaration found" + version_check = ... + } +} +``` +**Fix:** Ignore language server errors for `locals` in `provider_policy` - they're false positives + + +### ❌ Mistake 6: Filter Without Length Check +```hcl +# Suboptimal - doesn't filter empty collections +filter = attrs.ingress != null + +# Better - filters both null and empty +filter = attrs.ingress != null && core::length(attrs.ingress) > 0 +``` +**Fix:** Check both null and length for better performance + +### ❌ Mistake 7: Multi-line Boolean Expressions +```hcl +# Wrong - causes syntax errors +is_exact = local.has_version && + !core::contains(meta.version, "~>") && + !core::contains(meta.version, ">") + +# Correct - all on one line +is_exact = local.has_version && !core::contains(meta.version, "~>") && !core::contains(meta.version, ">") +``` +**Fix:** Put entire boolean expression on a single line + +### ❌ Mistake 8: Trying to Access Module Inputs +```hcl +# Wrong - module inputs not accessible yet +module_policy "*" "check" { + locals { + dns_enabled = attrs.enable_dns_hostnames # ❌ Not supported + } +} +``` +**Fix:** Module inputs via `attrs.*` are work in progress. You can only check `meta.source`, `meta.version`, `meta.address` + +### ❌ Mistake 9: Using Substring for Module Targeting +```hcl +# Wrong - substring matching doesn't work +module_policy "vpc" "check" { } # Won't match modules with "vpc" in source + +# Correct - use full source path or wildcard +module_policy "app.terraform.io/myorg/vpc/aws" "check" { } +module_policy "*" "check" { } # For all modules +``` +**Fix:** Module targeting requires FULL source path, not substring + +### ❌ Mistake 10: Confusing Blocks with Attributes +```hcl +# Wrong - default_tags is a Block, needs [0] +provider_policy "aws" "check" { + locals { + tags = attrs.default_tags.tags # ❌ Error: no indices + } +} + +# Correct - use [0] for Blocks +provider_policy "aws" "check" { + locals { + tags = attrs.default_tags[0].tags # ✅ Works! + } +} +``` +**Fix:** Check provider schema - Blocks need `[0]` index, Attributes don't + +### ❌ Mistake 11: Using Source for Provider Targeting +```hcl +# Wrong - can't use source in first label +provider_policy "hashicorp/aws" "check" { } # Doesn't work + +# Correct - use provider TYPE +provider_policy "aws" "check" { + # meta.source available inside policy + # Example: meta.source = "registry.terraform.io/hashicorp/aws" +} +``` +**Fix:** provider_policy first label = TYPE ("aws"), not source ("hashicorp/aws") + +### ❌ Mistake 12: Unnecessary core::keys() for Length +```hcl +# Unnecessarily complex +has_tags = core::try(core::length(core::keys(attrs.tags)), 0) > 0 + +# Simpler - core::length() works directly on maps +has_tags = core::try(core::length(attrs.tags), 0) > 0 + +# Most readable +has_tags = attrs.tags != null && core::length(attrs.tags) > 0 +``` +**Fix:** `core::length()` works directly on maps - no need for `core::keys()` wrapper + +### ❌ Mistake 13: Using core::getresources() +```hcl +# ❌ WRONG — null or empty filter inside resource_policy runs for EVERY resource (O(N) cost) +resource_policy "aws_s3_bucket" "check" { + locals { + all_policies = core::getresources("aws_s3_bucket_policy", null) # unscoped + } +} +# ❌ ALSO WRONG — empty filter {} is equally unscoped inside resource_policy +resource_policy "aws_s3_bucket" "check" { + locals { + all_policies = core::getresources("aws_s3_bucket_policy", {}) + } +} + +# ✅ CORRECT — cache at top-level locals for plan-time join (runs once total) +locals { + all_policies = core::getresources("aws_s3_bucket_policy", {}) +} +``` +**Why:** Calling `core::getresources()` with an empty (`{}`) or null filter inside `resource_policy` fetches ALL resources of that type once per evaluated resource — O(N) calls for N resources. Cache in top-level `locals` to run once. + +**Exception — per-resource attribute filter (parent+child pattern):** When the filter value depends on the current resource's own attribute (e.g. `attrs.id`), the call cannot be pre-computed at the top level because `attrs` is only available inside `resource_policy`. In that case, an inline `core::getresources()` call with a **specific per-resource filter** is the correct pattern. Always use a specific filter — never pass `{}` or `null` inside a `resource_policy`. + +> **Dependent child resources — use inline filter, verify via Terraform Registry.** Before writing a direct `resource_policy "child_type"` block or a top-level cache for a cross-resource lookup, fetch the Terraform Registry documentation for the child resource type to determine whether it is structurally dependent on a parent resource: +> - URL pattern: `https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown` +> - Look for a `(Required)` argument in "Argument Reference" whose description references another AWS resource (e.g. "Bucket to which to apply the ACL", "ARN of the load balancer"). Check the usage examples — if they show `some_attr = aws_parent.name.id` or `.arn`, that confirms the dependency. +> - If the child IS dependent: use `resource_policy "aws_parent_type"` with an inline `core::getresources("aws_child_type", {linking_attr = attrs.id_or_arn})` when the enforcement goal is **detecting a missing child**. The linking attribute name comes from the required argument (e.g. `bucket`); use `attrs.id` if the examples assign `.id`, `attrs.arn` if they assign `.arn`. +> - If the enforcement goal is **checking the child's own properties** and the child can exist independently (its absence is not a violation by itself), write `resource_policy "aws_child_type"` directly — do not look it up inside the parent's policy. +> - If a resource type has **no linking attribute in either direction** — neither it nor the "parent" type references the other via `.id`, `.arn`, `.name`, or similar in the Terraform config — it is a **truly independent resource**. Write a **separate `resource_policy`** block for it. Never use `core::getresources()` inside another `resource_policy` to look up independent resources. +> - Never use a top-level empty-filter cache for dependent child resource types when doing per-parent enforcement — it fetches all resources globally and requires a manual HCL for-loop to re-associate them with the parent. + +**⛔ CRITICAL: This prohibition extends to ALL cases where the filter value comes from `attrs.*` — not just structurally-dependent child resources.** If you find yourself writing `[for r in local.all_X : r if r.link_attr == attrs.Y]` inside a `resource_policy`, that is the wrong pattern whenever an inline call with a specific filter would work. The top-level `{}` cache is only appropriate when the filter value is a **constant** (not derived from `attrs.*`) — for example, pre-fetching all autoscaling policies to filter by a constant `scalable_dimension` value inside `resource_policy`. For truly independent resources (no linking attribute in either direction), a top-level `{}` cache must NOT be used to implement cross-resource fallback logic between them — each independent resource type gets its own `resource_policy` block (see rule below). + +**For truly independent resources — those with NO linking attribute in either direction in the Terraform config — write a separate `resource_policy` block for each type. Do NOT use `core::getresources()` to check an independent resource inside another `resource_policy`.** + +The key test: look at the Terraform Registry documentation for both resource types. If neither resource has an attribute whose value is set to the other resource's `.id`, `.arn`, `.name`, or similar reference in example usage (e.g. `bucket = aws_s3_bucket.x.id`, `security_configuration = aws_emr_security_configuration.x.name`), the resources are independent — each gets its own `resource_policy` block. + +```hcl +# Example of a LINKING attribute (makes inline core::getresources() correct): +# aws_emr_cluster has: security_configuration = aws_emr_security_configuration.x.name +# → The cluster references the security config by name → inline lookup is valid. + +# Example of NO linking attribute (makes inline core::getresources() WRONG): +# aws_instance has NO attribute that references aws_ec2_instance_metadata_defaults. +# aws_ec2_instance_metadata_defaults has NO attribute that references aws_instance. +# → They are independent → each gets its own separate resource_policy block. +``` + +> **⛔ Sentinel fallback pattern between independent resources — never use core::getresources() as a fallback.** +> +> When a Sentinel policy has logic like: +> *"if resource A does not have attribute X configured, then check if independent resource B has Y as a fallback"* +> — TFPolicy **cannot express this cross-resource fallback** because A and B share no linking attribute. +> +> **Correct TFPolicy conversion:** +> 1. Use `filter` on resource A to skip instances where X is absent (they are out of scope for A's check). +> 2. Enforce Y directly on resource B via its own separate `resource_policy` block. +> 3. Document the limitation in the policy file with a `# LIMITATION:` comment. +> +> **Never** implement the fallback by calling `core::getresources("B", {})` at the top level and using the result inside A's `resource_policy` locals. That is a cross-resource check between independent resources — it is incorrect regardless of whether the filter is `{}` or a constant value. +> +> **Concrete example — aws_instance + aws_ec2_instance_metadata_defaults:** +> ```hcl +> # ❌ WRONG — top-level {} cache used to implement Sentinel fallback between independent resources +> locals { +> all_defaults = core::getresources("aws_ec2_instance_metadata_defaults", {}) +> defaults_compliant = core::length([for d in local.all_defaults : d if core::try(d.http_tokens, "") == "required"]) > 0 +> } +> resource_policy "aws_instance" "imdsv2" { +> locals { +> # ❌ Wrong: cross-resource fallback using independent resource +> is_compliant = local.has_metadata_options ? (local.http_tokens == "required") : local.defaults_compliant +> } +> enforce { condition = local.is_compliant ... } +> } +> +> # ✅ CORRECT — filter skips instances without metadata_options; independent resource checked separately +> resource_policy "aws_instance" "imdsv2" { +> filter = core::try(attrs.metadata_options, null) != null && core::try(core::length(attrs.metadata_options), 0) > 0 +> locals { +> http_tokens = core::try([for m in attrs.metadata_options : m][0].http_tokens, "") +> } +> enforcement_level = "advisory" +> enforce { condition = local.http_tokens == "required" ... } +> } +> +> resource_policy "aws_ec2_instance_metadata_defaults" "imdsv2" { +> locals { http_tokens = core::try(attrs.http_tokens, "") } +> enforcement_level = "advisory" +> enforce { condition = local.http_tokens == "required" ... } +> } +> ``` + +```hcl +# ❌ WRONG — top-level {} cache, then HCL-filtered by attrs.* value inside resource_policy +locals { + all_sec_configs = core::getresources("aws_emr_security_configuration", {}) +} +resource_policy "aws_emr_cluster" "check" { + locals { + # ❌ Wrong pattern: fetches all globally and re-filters per cluster + matching = [for sc in local.all_sec_configs : sc if sc.name == attrs.security_configuration] + } +} + +# ❌ ALSO WRONG — same anti-pattern restructured as a top-level lookup map. +# Building { name => value } at the top level and indexing by an attrs.*-derived key +# inside resource_policy is semantically equivalent to the {} cache + HCL for-loop above. +# The lookup key (local.security_config_name = core::try(attrs.security_configuration, "")) +# is still derived from attrs.*, so this violates the same rule. +locals { + all_security_configs = core::getresources("aws_emr_security_configuration", {}) + security_config_map = { + for sc in local.all_security_configs : + core::try(sc.name, "") => core::try(sc.configuration, "") + } +} +resource_policy "aws_emr_cluster" "check" { + locals { + security_config_name = core::try(attrs.security_configuration, "") + # ❌ Wrong: map lookup by attrs.*-derived key is an attrs.* filter in disguise + security_config_json = core::try(local.security_config_map[local.security_config_name], "") + } +} + +# ✅ CORRECT — inline call with filter derived from attrs.* +resource_policy "aws_emr_cluster" "check" { + locals { + matching = core::getresources("aws_emr_security_configuration", { + name = attrs.security_configuration + }) + } +} + +# ✅ CORRECT — inline call with per-resource filter using attrs.id +# NOTE: This policy contains a cross-resource reference that will not resolve during plan time, +# but the policy will run successfully during apply time. +resource_policy "aws_s3_bucket" "public_access_required" { + locals { + public_access_block = core::getresources("aws_s3_bucket_public_access_block", { + bucket = attrs.id # Use attrs.id: Terraform sets child.bucket = parent.id + }) + block_public_acls = core::try(local.public_access_block[0].block_public_acls, false) + block_public_policy = core::try(local.public_access_block[0].block_public_policy, false) + ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false) + restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false) + } + enforce { + condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets + error_message = "S3 bucket must have all public access block settings enabled." + } +} +``` +See the `core::getresources()` decision guide in Critical Rule 2 above for the full pattern guidance. + +### ❌ Mistake 14: Using core::getdatasource() Inside resource_policy +```hcl +# ❌ WRONG - Makes API calls for EVERY resource! +resource_policy "aws_s3_bucket" "check" { + locals { + account_id = core::getdatasource("aws_caller_identity", {}) + } +} + +# ✅ CORRECT - Cache in top-level locals +locals { + account_id = core::getdatasource("aws_caller_identity", {}) +} +``` +**Why:** Makes real provider API calls (not cached). Never use inside resource policies. + +### ❌ Mistake 15: Using .attrs with core::getresources() +```hcl +# ❌ WRONG +locals { + all_roles = core::getresources("aws_iam_role", {}) +} +resource_policy "aws_iam_role" "check" { + locals { + name = local.all_roles[0].attrs.name # ERROR! + } +} + +# ✅ CORRECT +resource_policy "aws_iam_role" "check" { + locals { + name = local.all_roles[0].name # Works! + } +} +``` +**Fix:** Resources from `core::getresources()` have attributes at top level, not through `.attrs`. + +### ❌ Mistake 16: Not Converting Sets to Lists for Indexing +```hcl +# ❌ WRONG - rule is a SET, cannot index +resource_policy "aws_s3_bucket_server_side_encryption_configuration" "check" { + locals { + sse_algo = attrs.rule[0].sse_algorithm # ERROR! + } +} + +# ✅ CORRECT - Convert set to list using for loop +resource_policy "aws_s3_bucket_server_side_encryption_configuration" "check" { + locals { + sse_algo = [for rule in attrs.rule : rule][0].sse_algorithm + } +} +``` +**Fix:** Sets cannot be indexed with `[0]`. Use `[for item in set : item][0]` to convert. + +### ❌ Mistake 17: Redundant Length Checks — and When to Keep Them + +`core::try()` catches index-out-of-range errors, so a bare length check before `[0]` inside a `condition =` expression is technically redundant: + +```hcl +# ❌ Redundant in condition = expressions (core::try catches the out-of-range error) +condition = core::length(local.list) > 0 && core::try(local.list[0].value, "") == "expected" + +# ✅ Simpler — safe in condition = expressions +condition = core::try(local.list[0].value, "") == "expected" +``` + +**However**, when the list comes from `core::getresources()` (a child-resource lookup), always use an explicit length guard in `locals` before indexing with `[0]`. This makes the intent clear, is consistent with policies that inspect nested attribute blocks, and avoids relying on `core::try` to silently swallow a structural absence: + +```hcl +# ✅ PREFERRED for core::getresources() results — explicit guard before [0] +locals { + bucket_acl_resources = core::getresources("aws_s3_bucket_acl", { bucket = attrs.id }) + has_acl = core::length(local.bucket_acl_resources) > 0 + acl_value = local.has_acl ? core::try(local.bucket_acl_resources[0].acl, "") : "" +} + +# ✅ PREFERRED for nested block lists from getresources() — guard each level +locals { + sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", { bucket = attrs.id }) + has_sse_config = core::length(local.sse_configs) > 0 + sse_rules = local.has_sse_config ? core::try([for r in local.sse_configs[0].rule : r], []) : [] + has_sse_rule = core::length(local.sse_rules) > 0 + sse_apply_block = local.has_sse_rule ? core::try([for a in local.sse_rules[0].apply_server_side_encryption_by_default : a], []) : [] + has_apply_block = core::length(local.sse_apply_block) > 0 + sse_algorithm = local.has_apply_block ? core::try(local.sse_apply_block[0].sse_algorithm, "") : "" +} + +# ❌ AVOID for getresources() results — relies on core::try to mask absent child resource +locals { + sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", { bucket = attrs.id }) + sse_rules = core::try([for r in local.sse_configs[0].rule : r], []) # no guard: absence silently swallowed + sse_algorithm = core::try(local.sse_rules[0].apply_server_side_encryption_by_default[0].sse_algorithm, "") +} +``` + +**Rule summary:** +- In a `condition =` expression: `core::try(list[0].attr, default)` without a length guard is acceptable. +- In a `locals` block with `core::getresources()` results: use `has_X = core::length(local.X) > 0` and guard each `[0]` access with `local.has_X ? ... : default`. This is the established pattern in all S3 child-resource policies and makes the "no child resource found" case explicit. + +### ❌ Mistake 18: Expecting Cross-Resource References to Resolve at Plan Time +```hcl +# ❌ Won't match during initial creation +resource "aws_s3_bucket_server_side_encryption_configuration" "example" { + bucket = aws_s3_bucket.example.id # Reference not resolved at policy time +} + +# ✅ For testing, use literals +resource "aws_s3_bucket_server_side_encryption_configuration" "example" { + bucket = "my-bucket" # Literal value +} +``` +**Fix:** Cross-resource references aren't resolved at policy evaluation. Works best on existing infrastructure updates. + +### ❌ Mistake 19: Using meta.address in resource_policy +```hcl +# ❌ WRONG — meta.address is UNDEFINED in resource_policy real-plan evaluation +resource_policy "aws_s3_bucket" "check" { + enforce { + condition = !local.has_public_acl + error_message = "S3 bucket '${meta.address}' has a public ACL." # ERROR! + } +} + +# ✅ CORRECT — use static strings or safe attrs interpolation +resource_policy "aws_s3_bucket" "check" { + enforce { + condition = !local.has_public_acl + error_message = "S3 bucket has a prohibited public ACL. Set acl to 'private' or remove it." + # Or with dynamic context using a known attribute: + # error_message = "S3 bucket '${attrs.bucket}' has a prohibited public ACL." + } +} +``` +**Fix:** `meta.address` is UNDEFINED for `resource_policy` in real plan evaluation. It throws `Error: Unsupported attribute` for **every resource evaluated**, including compliant ones. `tfpolicy test` silently passes this bug — only `terraform plan --policies=` catches it. + +### ❌ Mistake 20: Incomplete Security Group Resource Coverage +```hcl +# ❌ WRONG — Only covers 2 of 4 SG resource types; misses modern VPC API +resource_policy "aws_security_group" "check" { ... } +resource_policy "aws_security_group_rule" "check" { ... } + +# ✅ CORRECT — Cover all 4 AWS security group resource types +resource_policy "aws_security_group" "check" { + # inline ingress/egress blocks; cidr_blocks in rule.cidr_blocks +} +resource_policy "aws_security_group_rule" "check" { + # standalone rules; cidr_blocks in attrs.cidr_blocks, type in attrs.type +} +resource_policy "aws_vpc_security_group_ingress_rule" "check" { + # modern VPC API (recommended); uses attrs.cidr_ipv4 NOT cidr_blocks + locals { + has_public_cidr = core::try(attrs.cidr_ipv4, "") == "0.0.0.0/0" + } +} +resource_policy "aws_default_security_group" "check" { + # default SG; same inline structure as aws_security_group +} +``` +**Key difference:** `aws_vpc_security_group_ingress_rule` uses `cidr_ipv4` (string) and `cidr_ipv6` (string), NOT `cidr_blocks` (list). Always include all 4 types for complete SG enforcement. + +**ELB family — 3 resource types required for complete listener/SSL enforcement:** +```hcl +# ❌ WRONG — Only covers Classic ELB; ALB/NLB (the modern standard) are silently skipped +resource_policy "aws_elb" "ssl_policy_check" { ... } + +# ✅ CORRECT — Cover all 3 ELB resource types +resource_policy "aws_elb" "ssl_policy_check" { + # Classic ELB: check aws_load_balancer_policy + aws_load_balancer_listener_policy + # listeners use attrs.listener[*].lb_protocol +} +resource_policy "aws_lb_listener" "ssl_policy_check" { + # ALB/NLB: ssl_policy is a direct attribute on the listener + filter = core::contains(["HTTPS", "TLS"], core::try(attrs.protocol, "")) + locals { + ssl_policy = core::try(attrs.ssl_policy, "") + } + enforce { + condition = core::contains(local.allowed_policies, local.ssl_policy) + error_message = "ALB/NLB listener must use an approved SSL/TLS security policy." + } +} +resource_policy "aws_alb_listener" "ssl_policy_check" { + # aws_alb_listener is an alias for aws_lb_listener — same attributes, same checks + filter = core::contains(["HTTPS", "TLS"], core::try(attrs.protocol, "")) + locals { + ssl_policy = core::try(attrs.ssl_policy, "") + } + enforce { + condition = core::contains(local.allowed_policies, local.ssl_policy) + error_message = "ALB/NLB listener must use an approved SSL/TLS security policy." + } +} +``` +**Key difference:** `aws_lb_listener`/`aws_alb_listener` has `ssl_policy` as a direct attribute; `aws_elb` requires cross-resource checks via `aws_load_balancer_policy`. Always cover all 3 types for complete ELB enforcement. + +### ❌ Mistake 21: Using core::try Default to Mask Missing Attributes +```hcl +# ❌ WRONG — resources where acl is NOT SET get defaulted to "private" and silently pass. +# This does NOT affect resources where acl IS set to "public-read" — those still fail correctly. +# The problem is resources with no acl configured are treated as compliant when they may not be. +resource_policy "aws_s3_bucket" "check" { + locals { + acl_value = core::try(attrs.acl, "private") # missing acl → "private" → passes silently + is_violation = core::contains(["public-read", "public-read-write"], local.acl_value) + } + enforce { + condition = !local.is_violation + } +} + +# ✅ CORRECT — use filter to only evaluate resources that have the attribute set +resource_policy "aws_s3_bucket" "check" { + filter = core::try(attrs.acl, null) != null # skip resources with no acl configured + + locals { + acl_value = attrs.acl # safe after filter + is_violation = core::contains(["public-read", "public-read-write"], local.acl_value) + } + enforce { + condition = !local.is_violation + } +} +``` +**Fix — two cases:** +- *Attribute absent = resource out of scope* (resource doesn't configure the feature at all → skip it): use `filter = core::try(attrs.field, null) != null` to exclude those resources. +- *Attribute absent = AWS provider default applies* (e.g. `encrypted` absent → AWS defaults to `false`, `enabled` absent → AWS defaults to `true`): the resource **is** in scope and should be evaluated. Do **not** filter on null — use `core::try(attrs.field, )` in the `condition` so the effective default is checked. Filtering out these resources would silently pass non-compliant configurations. + +> **Deciding which case applies:** check the Terraform provider documentation for the attribute. If it says "Default: `false`" or "Default: `true`", the absence carries a meaningful value → use `core::try` with the provider default in condition. If the attribute is truly optional with no provider default (its absence means "this block is not configured"), use `filter` to exclude it. + +### ❌ Mistake 22: Using expect_failure on data source blocks +```hcl +# ❌ WRONG — tfpolicy test does not support expect_failure on data blocks +data "aws_iam_policy_document" "test" { + expect_failure = true # ERROR: unsupported argument + attrs = { ... } +} + +# ✅ CORRECT — expect_failure is only valid on resource blocks +resource "aws_s3_bucket" "test_violation" { + expect_failure = true + attrs = { acl = "public-read" } +} +``` +**Fix:** `expect_failure` is only supported on `resource` test blocks, not `data` blocks. + +### ❌ Mistake 23: Assuming TFPolicy Cannot Check Integer Port Ranges +```hcl +# ❌ WRONG — Adds a false limitation and requires from_port == to_port, +# which incorrectly rejects valid port-range rules. +locals { + # LIMITATION: TF Policy cannot dynamically iterate integer ranges. + # This implementation requires from_port == to_port (single-port rule only). + is_single_port = local.from_port == local.to_port + port_authorized = core::contains(local.authorized_ports, local.from_port) + is_compliant = local.is_single_port && local.port_authorized +} + +# ❌ ALSO WRONG — core::range() with dynamic attrs.* values silently returns +# an empty list in the policytest framework (policytest limitation only). +# Do not use core::range() with dynamic port attributes. +locals { + ports_in_range = core::range(local.from_port, local.to_port + 1) # empty in policytest! + all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)]) +} + +# ✅ CORRECT — count how many authorized ports fall inside [from_port, to_port]. +# If the count equals the total number of ports in the range, all are authorized. +# Works with dynamic attrs.* values at both plan time and in policytest. +locals { + authorized_ports = [80, 443] # or from input block + from_port = core::try(attrs.from_port, 0) + to_port = core::try(attrs.to_port, 0) + authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port] + all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1) +} +``` +**Rule:** TFPolicy CAN check whether all ports within a dynamic integer range `[from_port, to_port]` are authorized. Never add a "cannot iterate integer ranges" limitation — it is false. + +**Why the count approach works:** +- Filter `authorized_ports` to those within `[from_port, to_port]` +- If every port in the range is authorized, the filtered count equals `to_port - from_port + 1` +- No range iteration needed — avoids the `core::range()` + dynamic value policytest issue entirely +- Verified working with dynamic `attrs.from_port` / `attrs.to_port` values + +**Caution with `core::range()`:** `core::range(start, end)` works correctly with hardcoded literals. With dynamic `attrs.*` integer values, it silently returns an empty list in the **policytest** framework (policytest limitation). Prefer the count approach for port-range policies to guarantee consistent behaviour in both tests and runtime. + +--- + +### ❌ Mistake 24: Accessing Optional Nested Blocks Without Null Safety ("unknown condition" error) +```hcl +# ❌ WRONG — ebs_block_device is optional; absent on instances without EBS. +# If the block is missing, attrs.ebs_block_device[0] is null, +# local.ebs.encrypted is unknown, and condition = unknown throws: +# Error: unknown condition +resource_policy "aws_instance" "ebs_encrypted" { + locals { + ebs = attrs.ebs_block_device[0] # ❌ null if block absent + is_encrypted = local.ebs.encrypted # ❌ unknown propagation + } + enforce { + condition = local.is_encrypted == true # ❌ "unknown condition" ERROR + error_message = "EBS block devices must be encrypted." + } +} + +# ❌ ALSO WRONG — Multiple optional nested blocks combined without null guards. +# If either block is absent, the condition evaluates to unknown. +resource_policy "aws_instance" "check" { + locals { + ebs_encrypted = attrs.ebs_block_device[0].encrypted # ❌ crashes if absent + nic_sg = attrs.network_interface[0].security_groups # ❌ crashes if absent + } + enforce { + condition = local.ebs_encrypted == true && core::length(local.nic_sg) > 0 + } +} + +# ✅ CORRECT — Use filter to scope to resources with the block, +# convert set to list, then use core::try for safe attribute access. +resource_policy "aws_instance" "ebs_encrypted" { + # Only evaluate instances that have at least one EBS block device configured + filter = core::try(attrs.ebs_block_device, null) != null && core::try(core::length(attrs.ebs_block_device), 0) > 0 + + locals { + ebs_devices = [for d in attrs.ebs_block_device : d] # set → list + # Count devices that are NOT encrypted; if zero, all are encrypted + unencrypted = [for d in local.ebs_devices : d if !core::try(d.encrypted, false)] + all_encrypted = core::length(local.unencrypted) == 0 + } + + enforce { + condition = local.all_encrypted + error_message = "All EBS block devices on the instance must have encryption enabled." + } +} + +# ✅ CORRECT — Multiple optional nested blocks: guard each independently. +resource_policy "aws_instance" "check" { + locals { + ebs_raw = core::try(attrs.ebs_block_device, null) + has_ebs = local.ebs_raw != null ? core::length(local.ebs_raw) > 0 : false + ebs_devices = local.has_ebs ? [for d in local.ebs_raw : d] : [] + # ✅ Use "no unencrypted devices" pattern — core::alltrue() does NOT exist + unencrypted = [for d in local.ebs_devices : d if core::try(d.encrypted, false) != true] + all_encrypted = !local.has_ebs || core::length(local.unencrypted) == 0 + } + + enforce { + condition = local.all_encrypted + error_message = "All EBS block devices must be encrypted." + } +} +``` +**Rule:** Optional nested blocks (those that may be absent on some resource instances) must always be guarded with `filter` or `core::try` before indexing. Accessing `attrs.block[0]` on an absent block propagates `null` through every dependent local, eventually reaching the `condition` expression as an unknown value — which tfpolicy cannot reduce to a boolean, causing `Error: unknown condition` at runtime. This error does **not** appear in `tfpolicy test` (mocked data always has the block present) — it only surfaces against real plans. + +**Pattern summary:** +1. Add `filter = core::try(attrs.block, null) != null && core::try(core::length(attrs.block), 0) > 0` to skip resources without the block. The double-`core::try` form is safe in both real plan evaluation and policytest mocks that omit the attribute. +2. Convert the block set to a list: `[for item in attrs.block : item]`. +3. Use `core::try(item.attr, )` on individual attributes inside the loop. +4. When a block is truly optional and its absence means "compliant", use `!local.has_block || ` so resources without the block pass automatically. + +--- + +### ❌ Mistake 25: `core::anytrue()` and `core::alltrue()` Do Not Exist + +**CRITICAL:** `core::anytrue()` and `core::alltrue()` are **not available** in the tfpolicy runtime. Using them anywhere — including in `locals`, in `for...if` filter expressions, or in `enforce` conditions — will produce: + +``` +Error: Call to unknown function +There is no function named "anytrue" in namespace core::. +``` + +or + +``` +Error: Call to unknown function +There is no function named "alltrue" in namespace core::. +``` + +```hcl +# ❌ WRONG — core::alltrue() does NOT exist in tfpolicy runtime +locals { + all_encrypted = core::alltrue([for d in local.devices : core::try(d.encrypted, false)]) +} + +# ❌ WRONG — core::anytrue() does NOT exist in tfpolicy runtime +locals { + any_public = core::anytrue([for r in local.rules : r.cidr == "0.0.0.0/0"]) +} + +# ✅ CORRECT — use core::length() with list comprehension instead of core::alltrue() +locals { + # "all encrypted" = no unencrypted devices exist + unencrypted = [for d in local.devices : d if !core::try(d.encrypted, false)] + all_encrypted = core::length(local.unencrypted) == 0 +} + +# ✅ CORRECT — use core::length() instead of core::anytrue() +locals { + # "any public" = at least one public rule exists + public_rules = [for r in local.rules : r if r.cidr == "0.0.0.0/0"] + any_public = core::length(local.public_rules) > 0 +} + +# ✅ CORRECT — boolean conditions in for...if: use plain && / || operators +locals { + bad_rules = [ + for rule in attrs.ingress : rule + if (rule.protocol == "tcp" && rule.from_port == 22) # ✅ plain boolean — safe + ] + + wide_open_rules = [ + for rule in attrs.ingress : rule + if (rule.cidr_blocks == ["0.0.0.0/0"] || rule.ipv6_cidr_blocks == ["::/0"]) # ✅ safe + ] +} + +# ✅ ALSO CORRECT — two-pass pattern for complex conditions +locals { + ingress_with_flags = [ + for rule in attrs.ingress : { + rule = rule + is_ssh_tcp = rule.protocol == "tcp" && rule.from_port == 22 + } + ] + ssh_tcp_rules = [for r in local.ingress_with_flags : r.rule if r.is_ssh_tcp] # ✅ safe +} +``` + +**Rule:** `core::anytrue()` and `core::alltrue()` do not exist. Replace them with `core::length()` patterns: +- Instead of `core::anytrue(list_of_bools)` → `core::length([for b in list_of_bools : b if b]) > 0` +- Instead of `core::alltrue(list_of_bools)` → `core::length([for b in list_of_bools : b if !b]) == 0` +- For filtering: use plain `&&` / `||` boolean operators in `for...if` clauses instead. + +--- + +### ❌ Mistake 26: Assuming core::try() Returns the Default When an Attribute Is Explicitly null + +```hcl +# ❌ WRONG — destination_ranges exists but is null; core::try() does NOT catch null values. +# core::try() only catches attribute-access errors (missing attributes / index-out-of-bounds). +# When the attribute is present but set to null, core::try() returns null — NOT the default []. +# Passing null to core::length(), core::contains(), or a for-loop then causes: +# "Invalid value for "list" parameter: argument must not be null" +resource_policy "google_compute_firewall" "check" { + locals { + dest_ranges = core::try(attrs.destination_ranges, []) # ❌ returns null, not [] + bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"] # ❌ crashes + } +} + +# ❌ ALSO WRONG — looks safe but crashes in `locals`! TFPolicy does NOT short-circuit `&&` in locals. +# Both sides of `&&` are always evaluated, so core::length(null) is called even when +# logging_raw is null, causing: "Invalid value for 'collection': argument must not be null" +resource_policy "google_storage_bucket" "check" { + locals { + logging_raw = core::try(attrs.logging, null) + logging_present = local.logging_raw != null && core::length(local.logging_raw) > 0 # ❌ crashes when null! + } +} + +# ✅ CORRECT — explicitly check for null after core::try(), then fall back to []. +resource_policy "google_compute_firewall" "check" { + locals { + dest_ranges_raw = core::try(attrs.destination_ranges, null) + dest_ranges = local.dest_ranges_raw != null ? local.dest_ranges_raw : [] + bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"] + } +} + +# ✅ CORRECT — use ternary to guard core::length() call; ternary DOES short-circuit. +resource_policy "google_storage_bucket" "check" { + locals { + logging_raw = core::try(attrs.logging, null) + logging_not_null = local.logging_raw != null + logging_length = local.logging_not_null ? core::length(local.logging_raw) : 0 # ✅ ternary safe + logging_present = local.logging_not_null && local.logging_length > 0 + } +} + +# ✅ ALSO CORRECT — inline ternary in one line (equivalent to above). +locals { + dest_ranges = core::try(attrs.destination_ranges, null) != null ? attrs.destination_ranges : [] +} +``` + +**Rule:** `core::try(expr, default)` catches **attribute-access errors** (e.g. missing attribute, index out of range) and returns `default` in that case. It does **NOT** substitute `default` when the attribute exists but its value is `null`. Always use the two-step pattern: +1. `core::try(attrs.field, null)` — safe access; returns `null` on missing attribute OR on null value +2. `!= null ? attrs.field : ` — explicit null guard before passing to list functions + +**CRITICAL: `&&` does NOT short-circuit in TFPolicy `locals` blocks, `condition =` expressions inside `enforce {}` blocks, or `for...if` predicates.** Even if the left side `local.var != null` is false, the right side (e.g. `core::length(local.var)` or `core::contains([...], local.var)`) will still be evaluated and crash. Always use the ternary operator (`condition ? value_if_true : value_if_false`) to conditionally call functions on potentially-null values. + +```hcl +# ❌ WRONG — condition = also does NOT short-circuit; crashes when local.X is null +enforce { + condition = local.X != null && core::contains(["a", "b"], local.X) # ❌ crashes! +} + +# ✅ CORRECT — use ternary inside the locals block, then reference in condition +locals { + is_allowed = local.X != null ? core::contains(["a", "b"], local.X) : false +} +enforce { + condition = local.is_allowed +} + +# ❌ WRONG — for...if predicate also does NOT short-circuit; crashes when r.field is absent +violating = [ + for r in local.rules : r + if core::try(r.field, null) != null && core::contains(["a", "b"], r.field) # ❌ crashes! +] + +# ✅ CORRECT — use ternary in for...if predicate; also wrap the second access with core::try +violating = [ + for r in local.rules : r + if (core::try(r.field, null) != null ? core::contains(["a", "b"], core::try(r.field, "")) : false) +] +``` + +(Note: `&&` in `filter =` **does** short-circuit for null values in real plan evaluation — optional absent attributes are treated as `null` in the Terraform resource schema, so `local.raw != null && core::length(local.raw) > 0` (where `local.raw = core::try(attrs.field, null)`) is safe. However, **in policytest mocks**, if the mock completely omits an optional block attribute (rather than including it as `null`), the `attrs` object is a strict HCL literal that truly lacks that attribute. In that case, `core::try(attrs.field, null) != null && core::length(attrs.field) > 0` fails: `core::try` catches the error on the first access and returns null, but the second bare `attrs.field` still throws "does not have attribute named 'field'" because `&&` does not protect against the independent evaluation error. **Safe patterns that work in both real plans and policytest:** +- Pre-capture: `local.raw = core::try(attrs.field, null)` then `filter = local.raw != null && core::length(local.raw) > 0` +- Double-wrap: `filter = core::try(attrs.field, null) != null && core::try(core::length(attrs.field), 0) > 0`) + +**String interpolation + null:** `core::try()` catches **errors** (missing attributes, index out of range), NOT null values. `${core::try(local.X, "default")}` returns `null` — not `"default"` — when `local.X` is null, causing: `"The expression result is null. Cannot include a null value in a string template."`. Fix: use ternary in the string template: `${local.X != null ? local.X : "default"}`. + +**Affected functions:** `core::length()`, `core::contains()`, `core::join()`, `for` loops, and any function that requires a non-null list/map argument will crash if passed `null`. Apply the pattern wherever a list/set attribute may be absent **or** explicitly null in the provider schema. + +--- + +### ❌ Mistake 27: Duplicating Common Values Across Multiple Policy Blocks + +```hcl +# ❌ WRONG — The same allowlist is copied into every resource_policy block. +# Changing the list requires editing multiple blocks, which is error-prone. +resource_policy "azurerm_linux_virtual_machine" "allowed_sizes" { + locals { + allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"] + } + enforce { + condition = core::contains(local.allowed_sizes, core::try(attrs.size, "")) + error_message = "VM size is not in the allowed list." + } +} + +resource_policy "azurerm_windows_virtual_machine" "allowed_sizes" { + locals { + allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"] # ❌ duplicated value + } + enforce { + condition = core::contains(local.allowed_sizes, core::try(attrs.size, "")) + error_message = "VM size is not in the allowed list." + } +} + +# ✅ CORRECT — Extract the shared value to a top-level locals block. +# All policy blocks reference it via local.. One change updates every policy. +locals { + allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"] +} + +resource_policy "azurerm_linux_virtual_machine" "allowed_sizes" { + enforce { + condition = core::contains(local.allowed_sizes, core::try(attrs.size, "")) + error_message = "VM size is not in the allowed list." + } +} + +resource_policy "azurerm_windows_virtual_machine" "allowed_sizes" { + enforce { + condition = core::contains(local.allowed_sizes, core::try(attrs.size, "")) + error_message = "VM size is not in the allowed list." + } +} +``` + +**Rule:** When two or more `resource_policy`, `module_policy`, or `provider_policy` blocks share a local variable that holds the **same constant value** (e.g. an allowlist, blocklist, threshold, or configuration string), extract it to a **top-level `locals {}` block** and reference it as `local.` in each policy. This follows the DRY principle — the value has a single source of truth. + +**When to extract:** +- Any literal list, map, string, or number used identically in two or more policy blocks +- Computed values derived from the same constant inputs across multiple blocks (e.g. a formatted string built from shared constants) + +**When NOT to extract:** +- A value specific to exactly one resource type with no meaning outside that block +- Any value that depends on `attrs.*` — those are resource-scoped and must stay inside the policy block (top-level `locals` cannot access `attrs`) + +### ❌ Mistake 28: String Concatenation with `+` Is Not Supported + +**Error:** `Invalid operand — Unsuitable value for left operand: a number is required.` + +```hcl +# ❌ WRONG — The + operator does not concatenate strings in tfpolicy +locals { + pattern = "^com\\.amazonaws\\..+\\." + input.service_name # ❌ runtime error +} + +# ✅ CORRECT — Use ${ } interpolation for dynamic string building +locals { + pattern = "^com\\.amazonaws\\..+\\.${input.service_name}" + error_msg = "Service name must match pattern com.amazonaws..${input.service_name}." +} + +# ✅ ALSO CORRECT — Avoid dynamic regex patterns entirely; use core::contains_substring +locals { + service_ok = core::startswith(local.svc, "com.amazonaws.") && + core::contains_substring(local.svc, input.service_name) +} +``` + +**Rule:** String concatenation using `+` is **not supported** in tfpolicy HCL. Use `"${expr}"` interpolation syntax for all dynamic string construction. + +--- + +### ❌ Mistake 29: `enforcement_level` Defined Multiple Times + +**Error:** `Attribute redefined — The argument "enforcement_level" was already set at line X.` + +```hcl +# ❌ WRONG — enforcement_level appears twice, once before each enforce block +resource_policy "aws_vpc_endpoint" "check" { + locals { ... } + + enforcement_level = "advisory" + enforce { + condition = local.is_interface + error_message = "..." + } + + enforcement_level = "advisory" # ❌ ERROR: already defined above + enforce { + condition = local.service_matches + error_message = "..." + } +} + +# ✅ CORRECT — enforcement_level appears exactly once for the whole block +resource_policy "aws_vpc_endpoint" "check" { + locals { ... } + + enforcement_level = "advisory" # ✅ declared once + + enforce { + condition = local.is_interface + error_message = "..." + } + + enforce { + condition = local.service_matches + error_message = "..." + } +} +``` + +**Rule:** `enforcement_level` is an attribute of the policy block itself, not of each `enforce` block. Declare it **exactly once** per `resource_policy`/`module_policy`/`provider_policy` block, at the same level as `locals` and `enforce`. Multiple `enforce` blocks within one policy block all share the same `enforcement_level`. + +--- + +### ❌ Mistake 30: Referencing `local.*` Inside a For-Object Comprehension + +**Error:** `Undefined Reference — The reference "local.origin_domain" is not defined.` + +```hcl +# ❌ WRONG — keys defined inside the for-object literal are NOT in local scope +locals { + origin_checks = [ + for origin in local.origins : { + origin_domain = core::try(origin.domain_name, "") + is_s3_origin = core::contains_substring(local.origin_domain, ".s3.") # ❌ local.origin_domain is unknown here + is_compliant = !local.is_s3_origin || local.has_oac_id # ❌ local.is_s3_origin is unknown here + } + ] +} + +# ✅ CORRECT — repeat the expression inline; each key is independent +locals { + origin_checks = [ + for origin in local.origins : { + origin_domain = core::try(origin.domain_name, "") + is_s3_origin = core::contains_substring(core::try(origin.domain_name, ""), ".s3.") + is_compliant = !core::contains_substring(core::try(origin.domain_name, ""), ".s3.") || + (core::try(origin.oac_id, null) != null && core::try(origin.oac_id, "") != "") + } + ] +} + +# ✅ ALSO CORRECT — two-pass pattern: compute properties first, then combine +locals { + origins_with_props = [ + for origin in local.origins : { + domain = core::try(origin.domain_name, "") + has_oac = core::try(origin.origin_access_control_id, null) != null + } + ] + # Now the second pass can reference the object's own keys by iterating: + origin_checks = [ + for o in local.origins_with_props : { + is_s3 = core::contains_substring(o.domain, ".s3.") + is_compliant = !core::contains_substring(o.domain, ".s3.") || o.has_oac + } + ] +} +``` + +**Rule:** Inside a `for ... : { ... }` object comprehension, keys defined within the same object literal **cannot** be referenced via `local.key_name`. The `local.*` scope only contains entries from the surrounding `locals {}` block. Either: +1. Inline the expression wherever needed (repeat it), or +2. Use a two-pass approach: compute intermediate values in one for-comprehension, then reference them by the iteration variable in a second for-comprehension. + +--- + +### ❌ Mistake 31: Ternary Branches with Inconsistent Object Types + +**Error:** `The true and false result expressions must have consistent types. The 'true' value includes object attribute "X", which is absent in the 'false' value.` + +```hcl +# ❌ WRONG — the true branch produces an object with "Statement" key, +# but the false branch is an empty object {} without that key +locals { + policy_doc = local.appears_inline ? core::try(core::jsondecode(local.policy_value), {}) : {} + statements = core::try(local.policy_doc.Statement, []) +} + +# ✅ CORRECT — both branches must produce objects with the SAME set of keys +locals { + policy_doc = local.appears_inline + ? core::try(core::jsondecode(local.policy_value), { Statement = [] }) + : { Statement = [] } + statements = core::try(local.policy_doc.Statement, []) +} + +# ✅ ALSO CORRECT — avoid the ternary entirely; use core::try for the safe path +locals { + # Always parse; core::try returns empty-statement fallback if decoding fails or is not applicable + policy_doc = core::try(core::jsondecode(local.policy_value), { Statement = [] }) + statements = core::try(local.policy_doc.Statement, []) +} +``` + +**Rule:** In tfpolicy HCL, both branches of `cond ? a : b` must return values of **identical type and shape**. This is especially important for objects: if the true branch returns an object with key `K`, the false branch must also include key `K` with a compatible type. Mismatched object shapes cause a compile-time type error. Use a consistent fallback object (e.g. `{ Statement = [] }`) or avoid the ternary by using `core::try` directly on the full expression. + +--- + +### ❌ Mistake 32: `inputs` Block Inside a Resource Test Block + +**Error:** `An argument named "inputs" is not expected here. Did you mean to define a block of type "inputs"?` + +```hcl +# ❌ WRONG — inputs block placed INSIDE a resource test block +resource "aws_db_instance" "pass_instance" { + inputs = { # ❌ inputs is not valid inside resource {} + resource_type = "aws_db_instance" + source_type = "db-instance" + } + attrs = { ... } +} + +# ✅ CORRECT — inputs is a TOP-LEVEL block in the policytest file, +# and it applies to ALL test cases in that file +policytest { + targets = ["my-policy.policy.hcl"] +} + +inputs { # ✅ top-level block, applies to all resources below + resource_type = "aws_db_instance" + source_type = "db-instance" +} + +resource "aws_db_instance" "pass_instance" { + attrs = { ... } +} +``` + +**Rule:** The `inputs {}` block is a **top-level** construct in a `.policytest.hcl` file. It overrides `input` block defaults for every test case in that file. It is **not** an attribute or a nested block inside `resource`, `data`, or `module` test blocks. If you need different input values for different test resources, put them in **separate `.policytest.hcl` files**, each with its own top-level `inputs {}` block. + +--- + +### ❌ Mistake 33: `data_policy` Block Type Does Not Exist + +**Error:** `Blocks of type "data_policy" are not expected here.` + +```hcl +# ❌ WRONG — data_policy is NOT a valid policy block type +data_policy "aws_iam_policy_document" "permissive_actions_denied" { + enforce { + condition = local.no_star_actions + error_message = "IAM policy documents must not use wildcard actions." + } +} + +# ✅ CORRECT — use resource_policy for actual Terraform resources +# To check IAM policy content, parse the inline_policy or the policy document +# that is attached to an actual resource (aws_iam_policy, aws_iam_role, etc.) +resource_policy "aws_iam_policy" "permissive_actions_denied" { + locals { + policy_doc = core::try(core::jsondecode(core::try(attrs.policy, "{}")), { Statement = [] }) + statements = core::try(local.policy_doc.Statement, []) + star_stmts = [for s in local.statements : s if core::contains(core::try(s.Action, []), "*")] + no_star_actions = core::length(local.star_stmts) == 0 + } + enforce { + condition = local.no_star_actions + error_message = "IAM managed policies must not use wildcard (*) actions." + } +} +``` + +**Rule:** The only valid top-level policy block types are: +- `resource_policy "" ""` — evaluates Terraform-managed resources +- `module_policy "" ""` — evaluates Terraform modules +- `provider_policy "" ""` — evaluates provider configuration + +`data_policy` **does not exist**. Data sources (e.g. `aws_iam_policy_document`, `aws_caller_identity`) are not evaluated via policy blocks. To enforce content constraints on IAM documents, write a `resource_policy` that targets the resource (`aws_iam_policy`, `aws_iam_role`, etc.) and parses the policy attribute using `core::jsondecode`. + +--- + +### ❌ Mistake 34: Duplicate Local Variable or Policy Block Definitions + +**Error (duplicate local):** `The local expression "..." is already defined. Each local expression must have a unique name.` +**Error (duplicate policy block):** `The resource block "..." is already defined. Each resource_policy block must have a unique resource type + name combination.` + +```hcl +# ❌ WRONG — same local variable name defined twice in the same locals {} block +resource_policy "aws_vpc" "flow_logging_enabled" { + locals { + flow_logs = core::getresources("aws_flow_log", {}) + matching_logs = [for fl in local.flow_logs : fl if fl.vpc_id == attrs.id] + flow_logs = core::try(attrs.enable_dns_support, false) # ❌ duplicate name! + } + enforce { ... } +} + +# ❌ WRONG — same resource_policy block defined twice (same type + name) +resource_policy "aws_vpc" "flow_logging_enabled" { + locals { ... } + enforce { condition = ... } +} + +resource_policy "aws_vpc" "flow_logging_enabled" { # ❌ duplicate! + locals { ... } + enforce { condition = ... } +} + +# ✅ CORRECT — unique local names + all checks in a single resource_policy block +# Fixes both mistakes above: +# 1. Each local has a distinct name (flow_logs vs dns_support) — no duplicate-name error. +# 2. Both checks live in one "aws_vpc" / "flow_logging_enabled" block with two enforce +# blocks — no duplicate-policy-block error. +# NOTE: aws_flow_log.vpc_id links to the parent VPC — filter inline by attrs.id; never use +# a top-level core::getresources("aws_flow_log", {}) + HCL for-loop filter (Mistake 13). +resource_policy "aws_vpc" "flow_logging_enabled" { + locals { + flow_logs = core::getresources("aws_flow_log", { vpc_id = attrs.id }) + dns_support = core::try(attrs.enable_dns_support, false) # unique name — no conflict + has_flow_log = core::length(local.flow_logs) > 0 + } + enforce { + condition = local.has_flow_log + error_message = "VPC must have at least one flow log configured." + } + enforce { + condition = local.dns_support + error_message = "VPC must have DNS support enabled." + } +} +``` + +**Rules:** +1. Within a single `locals {}` block, every local variable name must be **unique**. If you copy-paste or refactor, check for accidental name reuse. +2. Within a single policy file, every `resource_policy "type" "name"` combination must be **unique**. To add multiple checks on the same resource type, either add more `enforce` blocks to the existing policy block, or use a different `name` label (e.g. `"flow_logging_enabled"` vs `"flow_logging_destination"`). + +--- + +### ❌ Mistake 35: Using `||` Chains for Enum/Allowlist Checks Instead of `core::contains()` + +**Problem:** When checking if an attribute value belongs to a set of allowed values, chaining `||` comparisons is verbose, harder to maintain, and doesn't match idiomatic TF Policy style. + +```hcl +# ❌ WRONG — verbose chain, hard to maintain +locals { + ssl_mode = core::try(attrs.ssl_mode, null) + is_valid = local.ssl_mode == "require" || local.ssl_mode == "verify-ca" || local.ssl_mode == "verify-full" +} + +# ✅ CORRECT — core::contains() with a named list local +locals { + ssl_mode = core::try(attrs.ssl_mode, "none") + valid_modes = ["require", "verify-ca", "verify-full"] + is_valid = core::contains(local.valid_modes, local.ssl_mode) +} +enforce { + condition = local.is_valid + error_message = "Attribute 'ssl_mode' must be one of: require, verify-ca, verify-full." +} +``` + +**Rules:** +1. Whenever a Sentinel policy uses `value in [list]` or `value in set([...])`, always translate to `core::contains(allowed_list, value)` in TF Policy. +2. Store the allowed list in a named local variable (e.g., `valid_modes`) for readability. +3. **`core::contains(list, null)` is safe** — it returns `false` when value is `null`. No extra null guard is needed before calling `core::contains()`. +4. Use a non-null default in `core::try()` (e.g., `core::try(attrs.ssl_mode, "none")`) so `null` attribute values map to a clearly non-compliant default. + +--- + +### ❌ Mistake 36: Using Multiple `core::startswith()` Calls for Version Range Matching Instead of `core::regex()` + +**Problem:** When a Sentinel policy checks a version string with `>=` or `<` (e.g., `engine_version < "6.0"`), HCL does not support string comparison operators. Using multiple `core::startswith()` calls for each major version is verbose and brittle — it will break if a new major version prefix appears (e.g., `"0.x"` or `"10.x"`). + +```hcl +# ❌ WRONG — 5 separate startswith calls for versions 1.x through 5.x +locals { + is_version_lt_6 = ( + core::startswith(local.engine_version, "1.") || + core::startswith(local.engine_version, "2.") || + core::startswith(local.engine_version, "3.") || + core::startswith(local.engine_version, "4.") || + core::startswith(local.engine_version, "5.") + ) +} + +# ✅ CORRECT — core::regex() matches all versions with major version 1–5 in one expression +# Use in filter to skip resources where engine_version is >= 6.0 or unset +filter = core::try(attrs.engine_version, "") != "" && + core::try(core::regex("^[1-5]\\.", core::try(attrs.engine_version, "")), null) != null + +locals { + auth_token = core::try(attrs.auth_token, "") +} +enforce { + condition = local.auth_token != null && local.auth_token != "" + error_message = "Attribute 'auth_token' must be set when 'engine_version' < 6.0." +} +``` + +**Rules:** +1. When a Sentinel policy compares a version string with `<` or `>=`, identify the version boundary and translate to `core::regex()`. +2. **Always wrap in `core::try(..., null)`** — `core::regex()` returns `null` on no match (not `false`), and calling it on a null input causes an error. +3. Common patterns: + - Versions `< 6.0` (major 1–5): `core::regex("^[1-5]\\.", version)` + - Versions `>= 2.x` and `< 10.x`: `core::regex("^[2-9]\\.", version)` + - Patch versions like `"5.0.6"` or `"6.x"` are handled correctly by the major-version prefix pattern. +4. **Prefer `core::semverconstraint()`** if the version string is a proper SemVer (e.g., `"6.2.0"`); use `core::regex()` only for non-standard version strings (e.g., `"6.x"`, `"5.0.6"` from AWS ElastiCache engine versions). +5. **Never use `>`, `<`, `>=`, `<=` on strings in HCL** — HCL string comparison is lexicographic and unreliable for version ordering. + +--- + +### ❌ Mistake 37: Anchoring `resource_policy` on an Optional Companion Resource Instead of the Parent + +**Problem:** When a Sentinel policy checks an S3 companion resource (e.g., `aws_s3_bucket_public_access_block`), it is tempting to write `resource_policy "aws_s3_bucket_public_access_block"`. However, this companion resource is **optional** — a bucket can exist in a Terraform plan with no `aws_s3_bucket_public_access_block` at all. Anchoring on the companion means **buckets with no companion resource silently pass the policy**. + +```hcl +# ❌ WRONG — anchored on the companion type; buckets with no public_access_block resource silently pass +resource_policy "aws_s3_bucket_public_access_block" "block_public_access" { + locals { + block_public_acls = core::try(attrs.block_public_acls, false) + } + enforce { + condition = local.block_public_acls == true + error_message = "S3 bucket must block public ACLs." + } +} + +# ✅ CORRECT — anchored on the parent; a missing companion resource = false → violation is caught +# NOTE: apply-time cross-resource reference; resolves correctly at apply time. +resource_policy "aws_s3_bucket" "block_public_access" { + locals { + public_access_block = core::getresources("aws_s3_bucket_public_access_block", { + bucket = attrs.id + }) + block_public_acls = core::try(local.public_access_block[0].block_public_acls, false) + block_public_policy = core::try(local.public_access_block[0].block_public_policy, false) + ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false) + restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false) + } + + enforce { + condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets + error_message = "S3 bucket '${attrs.bucket}' must have all four public access block settings enabled." + } +} +``` + +**Rules:** +1. Always ask: "Can the parent (`aws_s3_bucket`) exist in a Terraform plan WITHOUT this companion?" If YES → anchor on the **parent**, not the companion. +2. The following S3 companions MUST always be checked via `resource_policy "aws_s3_bucket"`: + - `aws_s3_bucket_public_access_block` — lookup: `{ bucket = attrs.id }` (apply-time inline) + - `aws_s3_bucket_acl` — use parent anchor when enforcing that every bucket has a compliant ACL; use direct `resource_policy "aws_s3_bucket_acl"` only when checking ACL's own attribute values on ACLs that already exist + - `aws_s3_bucket_logging` — lookup: `{ bucket = attrs.id }` (apply-time inline) + - `aws_s3_bucket_server_side_encryption_configuration` — lookup: `{ bucket = attrs.id }` (apply-time inline) + - `aws_s3_bucket_versioning` — plan-time: top-level `core::getresources`, filter by `v.bucket == attrs.bucket` +3. **The Sentinel source pattern does not matter.** Even if the original Sentinel iterates over companion resource types, the TFPolicy MUST anchor on the parent. +4. **Requirement translation:** If requirement.txt says "every `aws_s3_bucket_public_access_block` must have X = true", reframe it as "every `aws_s3_bucket` must have a companion `aws_s3_bucket_public_access_block` with X = true; a missing companion is a violation." This reframing is mandatory before writing HCL. +5. Use apply-time inline `core::getresources("companion", { bucket = attrs.id })` inside the `resource_policy "aws_s3_bucket"` block. + +--- + +### ❌ Mistake 38: Checking Only `aws_iam_policy` for IAM Content Rules — Missing Inline Policy Resource Types + +**Symptom:** Policy only checks `aws_iam_policy` for privilege conditions (e.g. "no admin `*:*`") but misses inline policies attached directly to roles, users, and groups. + +```hcl +# ❌ INCOMPLETE — only catches standalone managed policies +resource_policy "aws_iam_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if ...] + } + enforce { condition = core::length(local.admin_stmts) == 0 ... } +} +# Missing: aws_iam_role_policy, aws_iam_user_policy, aws_iam_group_policy + +# ✅ CORRECT — cover all 4 inline policy resource types; each has attrs.policy (JSON string) +# Each resource_policy block is fully self-contained — attrs is only available inside a +# resource_policy, module_policy, or provider_policy block, not in top-level locals. + +resource_policy "aws_iam_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_role_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM role inline policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_user_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM user inline policies must not grant full admin privileges (*:* on *)." + } +} + +resource_policy "aws_iam_group_policy" "no_admin_privileges" { + locals { + statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, []) + admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")] + } + enforce { + condition = core::length(local.admin_stmts) == 0 + error_message = "IAM group inline policies must not grant full admin privileges (*:* on *)." + } +} +``` + +**Rule:** Any IAM content enforcement (wildcard actions, admin privileges, etc.) MUST cover all 4 inline policy resource types: +| Resource type | When it's used | `policy` attribute | +|---|---|---| +| `aws_iam_policy` | Standalone managed policy | JSON string | +| `aws_iam_role_policy` | Inline policy attached to a role | JSON string | +| `aws_iam_user_policy` | Inline policy attached to a user | JSON string | +| `aws_iam_group_policy` | Inline policy attached to a group | JSON string | + +All 4 have the same `attrs.policy` JSON string — use `core::jsondecode(core::try(attrs.policy, "{}"))` on each. + +**Note on `aws_iam_policy_document`:** This is a Terraform DATA SOURCE. `data_policy` does not exist in tfpolicy (see Mistake 31). Do NOT write a `resource_policy "aws_iam_policy_document"` to enforce IAM content — use the 4 managed/inline resource types above instead. + +--- + +### ❌ Mistake 39: Repeating `core::try()` Calls in Complex For-Loop Predicates + +**Problem:** When a `for...if` list comprehension has a complex filter predicate that references the same attribute multiple times via `core::try()` (e.g. `core::try(rule.from_port, 0)`, `core::try(rule.to_port, 0)`, `core::try(rule.protocol, "")` each appearing 3–5 times), the expression becomes an unreadable single line that is hard to maintain and verify. + +```hcl +# ❌ WRONG — core::try(rule.from_port, 0) appears 4 times; core::try(rule.to_port, 0) appears +# 4 times; core::try(rule.protocol, "") appears 5 times in one predicate. +violating_rules = [for rule in local.ingress_rules : rule if (core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0") || core::contains(core::try(rule.ipv6_cidr_blocks, []), "::/0")) && (core::try(rule.protocol, "") == "all" || core::try(rule.protocol, "") == "-1" || (core::try(rule.protocol, "") == "tcp" && core::length([for p in input.authorized_tcp_ports : p if p >= core::try(rule.from_port, 0) && p <= core::try(rule.to_port, 0)]) != (core::try(rule.to_port, 0) - core::try(rule.from_port, 0) + 1)) || (core::try(rule.protocol, "") == "udp" && core::length([for p in input.authorized_udp_ports : p if p >= core::try(rule.from_port, 0) && p <= core::try(rule.to_port, 0)]) != (core::try(rule.to_port, 0) - core::try(rule.from_port, 0) + 1)))] +``` + +```hcl +# ✅ CORRECT — two-phase approach: map items to enriched objects (extract sub-expressions +# into named fields), then filter on those named fields. +locals { + enriched_rules = [for rule in local.ingress_rules : { + rule = rule + has_public_ip = core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0") || core::contains(core::try(rule.ipv6_cidr_blocks, []), "::/0") + protocol = core::try(rule.protocol, "") + from_port = core::try(rule.from_port, 0) + to_port = core::try(rule.to_port, 0) + }] + + violating_rules = [for r in local.enriched_rules : r.rule if r.has_public_ip && (r.protocol == "all" || r.protocol == "-1" || (r.protocol != "tcp" && r.protocol != "udp") || (r.protocol == "tcp" && core::length([for p in input.authorized_tcp_ports : p if p >= r.from_port && p <= r.to_port]) != (r.to_port - r.from_port + 1)) || (r.protocol == "udp" && core::length([for p in input.authorized_udp_ports : p if p >= r.from_port && p <= r.to_port]) != (r.to_port - r.from_port + 1)))] +} +``` + +**When to use the two-phase pattern:** +1. A single field is referenced 3 or more times in the predicate via `core::try()` (each call is a repeated sub-expression). +2. The predicate contains a nested for-loop that re-accesses the same outer-loop variable. +3. The resulting predicate is too long to comfortably fit on a single line (required by Mistake 7). + +**Benefits:** +- Each `core::try()` call is written exactly once — no repeated accesses. +- Named fields (`r.from_port`, `r.protocol`) are self-documenting. +- The filter predicate is shorter and the structure matches the original Sentinel logic. +- Easier to debug: inspect `local.enriched_rules` directly to see computed intermediate values. + +**Rule summary:** When a for-loop predicate repeats the same `core::try(rule.field, default)` call more than twice, split into two steps: (1) map items to enriched objects with computed fields, (2) filter on those fields. See also the "Multi-Stage Filtering for Readability" best practice below. + +--- + +## Best Practices + +### ✅ Provider Schema Awareness +**Rule:** tfpolicy exposes raw provider schemas without transformation + +**Implication:** You need to understand the actual provider schema: +- Sets remain sets (not converted to lists) +- Know whether attributes are optional +- Understand nested object structures + +**Example:** +```hcl +# attrs.ingress is a SET (per AWS provider), but iteration works the same +for rule in attrs.ingress : rule.from_port +``` + +**Tip:** Use `terraform console` or provider docs to inspect schemas + +--- + +### ✅ Filter Pattern: Null + Length Check +**Rule:** Always check both null and length for collection filters; wrap both accesses when the attribute may be absent from policytest mocks + +```hcl +# ✅ Best practice — safe in real plans AND policytest mocks that omit the attribute +filter = core::try(attrs.ingress, null) != null && core::try(core::length(attrs.ingress), 0) > 0 + +# ✅ Also correct — pre-capture ensures the second operand references a local (never absent) +# local.ingress_raw = core::try(attrs.ingress, null) +# filter = local.ingress_raw != null && core::length(local.ingress_raw) > 0 + +# ⚠️ Works but less efficient (doesn't filter empty collections) +filter = core::try(attrs.ingress, null) != null + +# ❌ RISKY — second attrs.ingress access is not wrapped; fails in policytest mocks +# that completely omit the attribute (where attrs is a strict HCL object without the key) +filter = core::try(attrs.ingress, null) != null && core::length(attrs.ingress) > 0 +``` + +**Why:** In real Terraform plan evaluation, absent optional attributes are represented as `null` in the resource schema, and filter `&&` short-circuits after `null != null = false`. In policytest mocks however, if the mock omits an attribute, `attrs` is a strict HCL literal — the attribute genuinely does not exist, and the bare second access throws an error that `&&` does not protect against. Always wrap the second access with `core::try`. + +--- + +### ✅ Multi-Stage Filtering for Readability +**Rule:** Break complex logic into multiple local variables + +```hcl +# ✅ Good: Multi-stage filtering +locals { + # Stage 1: Filter to relevant items + ssh_rules = [ + for rule in attrs.ingress : + rule if rule.from_port <= 22 && rule.to_port >= 22 + ] + + # Stage 2: Filter to violations + public_ssh_rules = [ + for rule in local.ssh_rules : + rule if core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0") + ] + + # Stage 3: Check compliance + is_compliant = core::length(local.public_ssh_rules) == 0 +} + +``` + +**Benefits:** +- More readable and maintainable +- Easier to debug (inspect intermediate lists) +- Can reuse filtered lists for multiple checks +- Better error messages possible + +--- + +### ✅ Multiple Focused Policies +**Rule:** Use multiple `enforce` blocks within a single `resource_policy` block to separate concerns — do NOT split checks on the same resource type into multiple `resource_policy` blocks (SKILL.md Output Structure Rule 1). + +```hcl +# ✅ Correct — separate concerns via multiple enforce blocks in one block +resource_policy "aws_security_group" "security_group_checks" { + enforce { + condition = !local.has_public_ssh + error_message = "Security group must not allow public SSH ingress." + } + enforce { + condition = !local.has_public_rdp + error_message = "Security group must not allow public RDP ingress." + } + enforce { + condition = local.has_required_tags + error_message = "Security group must have required tags." + } +} + +# ❌ Wrong — same resource type split across multiple resource_policy blocks +resource_policy "aws_security_group" "ingress_check" { + # Check ingress rules only +} +resource_policy "aws_security_group" "egress_check" { + # Check egress rules only +} +``` + +**Benefits:** +- All checks on the same resource type are co-located and consistent +- Each `enforce` block reports independently — user sees all failures at once +- Easier to maintain and test + +--- + +--- + +### ✅ All Errors Shown Pattern +**Rule:** Multiple enforce blocks show all failures, not just first + +```hcl +resource_policy "aws_security_group" "comprehensive_check" { + enforce { + condition = !local.has_public_ssh + error_message = "SSH violation: ..." + } + + enforce { + condition = !local.has_public_rdp + error_message = "RDP violation: ..." + } + + enforce { + condition = local.has_description + error_message = "Description required: ..." + } +} +``` + +**Behavior:** User sees **all** failing messages in encounter order + +**Benefit:** Comprehensive feedback - users can fix all issues at once + +--- + +## Verified Capabilities by Policy Type + +### resource_policy +- ✅ Full `attrs.*` access, nested attributes via dot notation +- ✅ `meta.provider_type`, `meta.tfe_workspace` +- ❌ **`meta.address` is UNDEFINED in real plan evaluation** — do not use in `filter`, `locals`, `condition`, or `error_message`; it causes `Error: Unsupported attribute` at runtime. Note: `tfpolicy test` will NOT catch this error — only `terraform plan --policies=` will. +- ✅ `filter`, `locals`, multiple `enforce` blocks + +### module_policy +- ✅ `meta.source`, `meta.version`, `meta.address` +- ✅ `filter`, `locals`, multiple `enforce` blocks +- ❌ `attrs.*` (inputs) - work in progress +- ❌ `meta.tfe_workspace` - resource_policy only + +### provider_policy +- ✅ Full `attrs.*` (config), `meta.alias`, `meta.version`, `meta.source` +- ✅ `filter`, `locals`, multiple `enforce` blocks +- ❌ `meta.tfe_workspace` - resource_policy only + +**⚠️ `meta.version` is the resolved version (e.g. `"6.50.0"`), not the constraint string (e.g. `">= 4.0"`).** Use `core::semverconstraint(meta.version, "~> 5.0")` to enforce an approved range. Test mocks should use realistic resolved version numbers, not constraint strings. Verified on tfpolicy 0.0.2-beta20260513. + +**Targeting Pattern:** +| Policy Type | First Label | Example | +|-------------|-------------|---------| +| resource_policy | Resource TYPE | `"aws_instance"` | +| module_policy | Full SOURCE path | `"app.terraform.io/myorg/vpc/aws"` | +| provider_policy | Provider TYPE | `"aws"` (not `"hashicorp/aws"`) | + +--- + +--- + +## Testing + +See the [tfpolicy-test skill](tfpolicy-test.md) for comprehensive testing guidance. + +### Module mock syntax (two labels required) + +`module_policy` test mocks take **two labels** — source and a mock name — matching the policy block's own two-label signature: + +```hcl +# ✅ CORRECT — two labels: source pattern, then mock name +module "registry.terraform.io/hashicorp/consul/aws" "approved" { + meta = { + source = "registry.terraform.io/hashicorp/consul/aws" + version = "0.1.0" + address = "module.consul" + } +} + +# ❌ WRONG — one label causes parse error +module "registry.terraform.io/hashicorp/consul/aws" { + ... +} +``` + +The same applies to the policy block itself: + +```hcl +# ✅ CORRECT +module_policy "*" "require_private_registry" { ... } + +# ❌ WRONG — "Only 1 labels (source) are expected" error (misleading message; two ARE required) +module_policy "*" { ... } +``` + +Verified on tfpolicy 0.0.2-beta20260513. + +--- + +## Quick Decision Tree + +**Need to compare versions?** +→ SemVer strings (e.g. `"6.2.0"`): Use `core::semverconstraint()`. Non-SemVer strings (e.g. AWS `"6.x"`, `"5.0.6"`): Use `core::try(core::regex("^[1-5]\\.", version), null) != null` — see Mistake 36. + +**Checking if a value is one of several allowed values?** +→ Use `core::contains(allowed_list, value)` — NOT chained `||`. Store the list in a named local — see Mistake 35. + +**Enforcing a rule on an S3 companion resource (e.g. `public_access_block`, `acl`, `versioning`, `logging`)?** +→ ALWAYS anchor on `resource_policy "aws_s3_bucket"` with `core::getresources("companion", { bucket = attrs.id })` inside — NEVER anchor on the companion type directly — see Mistake 37. + +**Enforcing IAM content rules (no admin privileges, no wildcard actions, etc.)?** +→ Write 4 separate `resource_policy` blocks: `aws_iam_policy`, `aws_iam_role_policy`, `aws_iam_user_policy`, `aws_iam_group_policy`. All use `core::jsondecode(core::try(attrs.policy, "{}"))`. Do NOT use `aws_iam_policy_document` (`data_policy` does not exist) — see Mistake 38. + +**Need built-in Terraform function?** +→ Add `core::` prefix + +**Want to use locals in provider_policy?** +→ Go ahead! Language server errors are false + +**Need string pattern matching?** +→ ✅ `core::startswith(str, prefix)` and `core::endswith(str, suffix)` are available directly. For regex/substring use `core::try(core::regex("pattern", string), null) != null`. + +**Need to handle null values?** +→ Use `core::try(value, default)` + +**Testing policies?** +→ Create `.policytest.hcl` files and run `tfpolicy test` + +--- + +## Version Requirements + +- **Terraform**: >= 1.13.0-policyYYYYMMDD (private beta builds) +- **tfpolicy CLI**: >= 0.0.1-alphaYYYYMMDD +- **HCP Terraform**: Organization with policy feature enabled + +--- + +## Contact + +- **Questions:** team-tf-policy@wwpdl.vnet.ibm.com +- **Documentation:** See terraform-policy-agent-skill/ directory +- **Examples:** See the reusable patterns in [`tfpolicy-author.md`](tfpolicy-author.md) and any companion example directories that may exist in your broader beta workspace + +--- + +**Status:** ✅ All behaviors verified with user +**Ready for:** Agent skill usage, documentation generation, policy creation +**Last Review:** 2026-02-20 + +> **See Also:** [Authoring Reference](tfpolicy-author.md) diff --git a/.agents/skills/terraform-search-import/SKILL.md b/.agents/skills/terraform-search-import/SKILL.md new file mode 100644 index 0000000..380405e --- /dev/null +++ b/.agents/skills/terraform-search-import/SKILL.md @@ -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 ` +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=` +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 "" "" { + provider = # 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/".list_resource_schemas.""'` + + config { + filter { + name = "" + values = ["", ""] + } + 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: ` ` + +## 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 +``` diff --git a/.agents/skills/terraform-search-import/references/MANUAL-IMPORT.md b/.agents/skills/terraform-search-import/references/MANUAL-IMPORT.md new file mode 100644 index 0000000..d1245b3 --- /dev/null +++ b/.agents/skills/terraform-search-import/references/MANUAL-IMPORT.md @@ -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" +``` diff --git a/.agents/skills/terraform-search-import/scripts/list_resources.sh b/.agents/skills/terraform-search-import/scripts/list_resources.sh new file mode 100644 index 0000000..cfb74ad --- /dev/null +++ b/.agents/skills/terraform-search-import/scripts/list_resources.sh @@ -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 diff --git a/.agents/skills/terraform-stacks/SKILL.md b/.agents/skills/terraform-stacks/SKILL.md new file mode 100644 index 0000000..8115afe --- /dev/null +++ b/.agents/skills/terraform-stacks/SKILL.md @@ -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//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..` or `component.[key].` 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.[each.value].` +- Provider references are normal values: `provider..` or `provider..[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..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 diff --git a/.agents/skills/terraform-stacks/references/api-monitoring.md b/.agents/skills/terraform-stacks/references/api-monitoring.md new file mode 100644 index 0000000..d4c71c2 --- /dev/null +++ b/.agents/skills/terraform-stacks/references/api-monitoring.md @@ -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. diff --git a/.agents/skills/terraform-stacks/references/component-blocks.md b/.agents/skills/terraform-stacks/references/component-blocks.md new file mode 100644 index 0000000..1f70c32 --- /dev/null +++ b/.agents/skills/terraform-stacks/references/component-blocks.md @@ -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 = + description = "" + default = + sensitive = + nullable = + ephemeral = +} +``` + +### 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 { + = { + source = "" + version = "" + } +} +``` + +### 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 "" "" { + for_each = # Optional + + config { + + } +} +``` + +### 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 "" { + for_each = # Optional + + source = "" + + inputs = { + = + } + + providers = { + = provider..[] + } +} +``` + +### 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: `//` + +**Private HCP Terraform Registry:** +```hcl +source = "app.terraform.io/my-org/vpc/aws" +source = "app.terraform.io/example-corp/networking/azurerm" +``` +Format: `///` + +- **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..` + +For components with `for_each`: `component.[].` + +### 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 "" { + type = + description = "" + value = + sensitive = + ephemeral = +} +``` + +### 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 { + = +} +``` + +### 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. + source = "" + + providers = { + = provider.. + } +} +``` + +### 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 +} +``` diff --git a/.agents/skills/terraform-stacks/references/deployment-blocks.md b/.agents/skills/terraform-stacks/references/deployment-blocks.md new file mode 100644 index 0000000..f8d95ac --- /dev/null +++ b/.agents/skills/terraform-stacks/references/deployment-blocks.md @@ -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 "" { + audience = [] +} +``` + +### 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..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//locations/global/workloadIdentityPools//providers/"] +} +``` + +**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 { + = +} +``` + +### 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 "" { + inputs = { + = + } +} +``` + +### 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 "" { + deployments = [] +} +``` + +### 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 "" { + deployment_group = deployment_group. + + check { + condition = + reason = "" + } +} +``` + +### 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`. diff --git a/.agents/skills/terraform-stacks/references/examples.md b/.agents/skills/terraform-stacks/references/examples.md new file mode 100644 index 0000000..46e65d2 --- /dev/null +++ b/.agents/skills/terraform-stacks/references/examples.md @@ -0,0 +1,1529 @@ +# Terraform Stacks Complete Examples + +Complete, working examples for common Terraform Stacks scenarios. + +## Table of Contents + +1. [Simple Single-Region Stack](#simple-single-region-stack) +2. [Stack with Private Registry Modules](#stack-with-private-registry-modules) +3. [Multi-Environment Stack](#multi-environment-stack) +4. [Multi-Region Stack](#multi-region-stack) +5. [Linked Stacks (Cross-Stack Dependencies)](#linked-stacks-cross-stack-dependencies) +6. [Multi-Cloud Stack](#multi-cloud-stack) +7. [Complete AWS Production Stack](#complete-aws-production-stack) +8. [Destroying Deployments](#destroying-deployments) + +## Simple Single-Region Stack + +Basic Stack with a single environment deployment. + +### File Structure +``` +simple-stack/ +├── variables.tfcomponent.hcl +├── providers.tfcomponent.hcl +├── components.tfcomponent.hcl +├── deployments.tfdeploy.hcl +└── modules/ + └── webapp/ + ├── main.tf + ├── variables.tf + └── outputs.tf +``` + +### variables.tfcomponent.hcl +```hcl +variable "aws_region" { + type = string + default = "us-west-1" +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} + +variable "app_name" { + type = string +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } +} + +provider "aws" "main" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + } +} +``` + +### components.tfcomponent.hcl +```hcl +component "webapp" { + source = "./modules/webapp" + + inputs = { + app_name = var.app_name + region = var.aws_region + } + + providers = { + aws = provider.aws.main + } +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +deployment "production" { + inputs = { + aws_region = "us-west-1" + app_name = "my-webapp" + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "production" { + deployments = [deployment.production] +} +``` + +## Stack with Private Registry Modules + +Example Stack using modules from a private HCP Terraform registry, combining both private and public registry sources. + +### File Structure +``` +private-registry-stack/ +├── variables.tfcomponent.hcl +├── providers.tfcomponent.hcl +├── components.tfcomponent.hcl +├── outputs.tfcomponent.hcl +└── deployments.tfdeploy.hcl +``` + +### variables.tfcomponent.hcl +```hcl +variable "aws_region" { + type = string + default = "us-west-2" +} + +variable "environment" { + type = string +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} + +variable "vpc_cidr" { + type = string + default = "10.0.0.0/16" +} + +variable "app_name" { + type = string +} + +variable "db_password" { + type = string + sensitive = true +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5.0" + } +} + +provider "aws" "main" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + + default_tags { + tags = { + Environment = var.environment + ManagedBy = "Terraform Stacks" + Application = var.app_name + } + } + } +} + +provider "random" "main" { + config {} +} +``` + +### components.tfcomponent.hcl +```hcl +locals { + name_prefix = "${var.app_name}-${var.environment}" + common_tags = { + Project = var.app_name + Environment = var.environment + } +} + +# Using a private registry module for VPC +component "vpc" { + source = "app.terraform.io/my-org/vpc/aws" + version = "2.1.0" + + inputs = { + name_prefix = local.name_prefix + cidr_block = var.vpc_cidr + availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"] + enable_nat_gateway = true + single_nat_gateway = var.environment != "prod" + tags = local.common_tags + } + + providers = { + aws = provider.aws.main + } +} + +# Using a private registry module for security groups +component "security_groups" { + source = "app.terraform.io/my-org/security-groups/aws" + version = "1.5.2" + + inputs = { + vpc_id = component.vpc.vpc_id + name_prefix = local.name_prefix + environment = var.environment + } + + providers = { + aws = provider.aws.main + } +} + +# Using a public registry module for RDS +component "database" { + source = "terraform-aws-modules/rds/aws" + version = "~> 6.0" + + inputs = { + identifier = "${local.name_prefix}-db" + engine = "postgres" + engine_version = "15.3" + family = "postgres15" + major_engine_version = "15" + instance_class = var.environment == "prod" ? "db.t3.large" : "db.t3.micro" + + allocated_storage = var.environment == "prod" ? 100 : 20 + db_name = replace(var.app_name, "-", "_") + username = "dbadmin" + password = var.db_password + port = 5432 + + db_subnet_group_name = component.vpc.database_subnet_group_name + vpc_security_group_ids = [component.security_groups.database_sg_id] + + backup_retention_period = var.environment == "prod" ? 30 : 7 + skip_final_snapshot = var.environment != "prod" + deletion_protection = var.environment == "prod" + + tags = local.common_tags + } + + providers = { + aws = provider.aws.main + } +} + +# Using a private registry module for application infrastructure +component "application" { + source = "app.terraform.io/my-org/ecs-application/aws" + version = "3.2.1" + + inputs = { + name_prefix = local.name_prefix + vpc_id = component.vpc.vpc_id + private_subnet_ids = component.vpc.private_subnet_ids + public_subnet_ids = component.vpc.public_subnet_ids + app_security_group_id = component.security_groups.app_sg_id + + container_image = "my-org/my-app:latest" + container_port = 8080 + desired_count = var.environment == "prod" ? 3 : 1 + + environment_variables = { + ENVIRONMENT = var.environment + DATABASE_HOST = component.database.db_instance_endpoint + DATABASE_NAME = component.database.db_instance_name + } + + tags = local.common_tags + } + + providers = { + aws = provider.aws.main + } +} +``` + +### outputs.tfcomponent.hcl +```hcl +output "vpc_id" { + type = string + description = "VPC ID" + value = component.vpc.vpc_id +} + +output "application_url" { + type = string + description = "Application load balancer URL" + value = component.application.load_balancer_dns +} + +output "database_endpoint" { + type = string + description = "Database endpoint" + value = component.database.db_instance_endpoint + sensitive = true +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" +} + +deployment "development" { + inputs = { + aws_region = "us-west-2" + environment = "dev" + app_name = "myapp" + vpc_cidr = "10.0.0.0/16" + db_password = "dev-password-change-me" + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "production" { + inputs = { + aws_region = "us-east-1" + environment = "prod" + app_name = "myapp" + vpc_cidr = "10.1.0.0/16" + db_password = "prod-password-use-secrets-manager" + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "development" { + deployments = [deployment.development] +} + +deployment_group "production" { + deployments = [deployment.production] +} +``` + +### Key Points + +- **Private registry modules** use the format `app.terraform.io///` +- **Version constraints** ensure consistent module versions across environments +- **Mixed sources**: Combining private registry modules (VPC, security groups, application) with public registry modules (RDS) +- **Authentication**: HCP Terraform workspaces automatically authenticate to private registries; CLI users need credentials configured +- **Terraform Enterprise**: Replace `app.terraform.io` with your instance hostname + +## Multi-Environment Stack + +Stack with development, staging, and production deployments. + +### variables.tfcomponent.hcl +```hcl +variable "aws_region" { + type = string +} + +variable "environment" { + type = string +} + +variable "instance_count" { + type = number +} + +variable "instance_type" { + type = string +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } +} + +provider "aws" "this" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + + default_tags { + tags = { + Environment = var.environment + ManagedBy = "Terraform Stacks" + } + } + } +} +``` + +### components.tfcomponent.hcl +```hcl +locals { + name_prefix = "myapp-${var.environment}" +} + +component "vpc" { + source = "./modules/vpc" + + inputs = { + name_prefix = local.name_prefix + cidr_block = "10.0.0.0/16" + } + + providers = { + aws = provider.aws.this + } +} + +component "compute" { + source = "./modules/compute" + + inputs = { + name_prefix = local.name_prefix + vpc_id = component.vpc.vpc_id + subnet_ids = component.vpc.private_subnet_ids + instance_count = var.instance_count + instance_type = var.instance_type + } + + providers = { + aws = provider.aws.this + } +} +``` + +### outputs.tfcomponent.hcl +```hcl +output "vpc_id" { + type = string + value = component.vpc.vpc_id +} + +output "load_balancer_url" { + type = string + value = component.compute.load_balancer_url +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" + + environments = { + dev = { + region = "us-east-1" + instance_count = 1 + instance_type = "t3.micro" + } + staging = { + region = "us-west-1" + instance_count = 2 + instance_type = "t3.small" + } + prod = { + region = "us-west-1" + instance_count = 5 + instance_type = "t3.large" + } + } +} + +deployment "development" { + inputs = { + aws_region = local.environments.dev.region + environment = "dev" + instance_count = local.environments.dev.instance_count + instance_type = local.environments.dev.instance_type + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "staging" { + inputs = { + aws_region = local.environments.staging.region + environment = "staging" + instance_count = local.environments.staging.instance_count + instance_type = local.environments.staging.instance_type + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "production" { + inputs = { + aws_region = local.environments.prod.region + environment = "prod" + instance_count = local.environments.prod.instance_count + instance_type = local.environments.prod.instance_type + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "development" { + deployments = [deployment.development] +} + +deployment_group "non_production" { + deployments = [deployment.staging] +} + +deployment_group "production" { + deployments = [deployment.production] +} + +# Auto-approve dev deployments +deployment_auto_approve "dev_auto" { + deployment_group = deployment_group.development + + check { + condition = context.plan.applyable + reason = "Development plans must be applyable" + } +} +``` + +## Multi-Region Stack + +Stack that deploys identical infrastructure across multiple AWS regions. + +### variables.tfcomponent.hcl +```hcl +variable "regions" { + type = set(string) +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} + +variable "app_name" { + type = string +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } +} + +provider "aws" "regional" { + for_each = var.regions + + config { + region = each.value + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + + default_tags { + tags = { + Region = each.value + ManagedBy = "Terraform Stacks" + AppName = var.app_name + } + } + } +} +``` + +### components.tfcomponent.hcl +```hcl +component "regional_infrastructure" { + for_each = var.regions + + source = "./modules/regional-infra" + + inputs = { + region = each.value + app_name = var.app_name + name_suffix = each.value + } + + providers = { + aws = provider.aws.regional[each.value] + } +} + +component "global_route53" { + source = "./modules/route53" + + inputs = { + app_name = var.app_name + domain_name = "example.com" + regional_lbs = { + for region, comp in component.regional_infrastructure : + region => comp.load_balancer_dns + } + } + + # Use one region's provider for global resources + providers = { + aws = provider.aws.regional["us-west-1"] + } +} +``` + +### outputs.tfcomponent.hcl +```hcl +output "regional_endpoints" { + type = map(string) + value = { + for region, comp in component.regional_infrastructure : + region => comp.load_balancer_url + } +} + +output "global_domain" { + type = string + value = component.global_route53.domain_name +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + regions = ["us-west-1", "us-east-1", "eu-west-1"] +} + +deployment "multi_region_prod" { + inputs = { + regions = toset(local.regions) + app_name = "my-global-app" + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "production" { + deployments = [deployment.multi_region_prod] +} +``` + +## Linked Stacks (Cross-Stack Dependencies) + +Two Stacks where the application Stack depends on the network Stack. + +### Network Stack + +#### network-stack/variables.tfcomponent.hcl +```hcl +variable "vpc_cidr" { + type = string +} + +variable "environment" { + type = string +} + +variable "aws_region" { + type = string +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} +``` + +#### network-stack/providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } +} + +provider "aws" "this" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + } +} +``` + +#### network-stack/components.tfcomponent.hcl +```hcl +component "vpc" { + source = "./modules/vpc" + + inputs = { + cidr_block = var.vpc_cidr + environment = var.environment + } + + providers = { + aws = provider.aws.this + } +} + +component "security_groups" { + source = "./modules/security-groups" + + inputs = { + vpc_id = component.vpc.vpc_id + environment = var.environment + } + + providers = { + aws = provider.aws.this + } +} +``` + +#### network-stack/outputs.tfcomponent.hcl +```hcl +output "vpc_id" { + type = string + value = component.vpc.vpc_id +} + +output "private_subnet_ids" { + type = list(string) + value = component.vpc.private_subnet_ids +} + +output "public_subnet_ids" { + type = list(string) + value = component.vpc.public_subnet_ids +} + +output "app_security_group_id" { + type = string + value = component.security_groups.app_sg_id +} +``` + +#### network-stack/deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" +} + +deployment "network" { + inputs = { + aws_region = "us-west-1" + environment = "production" + vpc_cidr = "10.0.0.0/16" + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Publish outputs for other stacks +publish_output "vpc_id_network" { + type = string + value = deployment.network.vpc_id +} + +publish_output "private_subnet_ids" { + type = list(string) + value = deployment.network.private_subnet_ids +} + +publish_output "public_subnet_ids" { + type = list(string) + value = deployment.network.public_subnet_ids +} + +publish_output "app_security_group_id" { + type = string + value = deployment.network.app_security_group_id +} + +# Deployment groups +deployment_group "network" { + deployments = [deployment.network] +} +``` + +### Application Stack + +#### application-stack/variables.tfcomponent.hcl +```hcl +variable "vpc_id" { + type = string +} + +variable "subnet_ids" { + type = list(string) +} + +variable "security_group_id" { + type = string +} + +variable "instance_count" { + type = number +} + +variable "aws_region" { + type = string +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} +``` + +#### application-stack/providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } +} + +provider "aws" "this" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + } +} +``` + +#### application-stack/components.tfcomponent.hcl +```hcl +component "application" { + source = "./modules/app" + + inputs = { + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + security_group_id = var.security_group_id + instance_count = var.instance_count + } + + providers = { + aws = provider.aws.this + } +} +``` + +#### application-stack/deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +# Reference the network stack +upstream_input "network" { + type = "stack" + source = "app.terraform.io/my-org/my-project/network-stack" +} + +deployment "application" { + inputs = { + aws_region = "us-west-1" + vpc_id = upstream_input.network.vpc_id_network + subnet_ids = upstream_input.network.private_subnet_ids + security_group_id = upstream_input.network.app_security_group_id + instance_count = 3 + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "application" { + deployments = [deployment.application] +} +``` + +## Multi-Cloud Stack + +Stack that deploys to both AWS and Azure. + +### variables.tfcomponent.hcl +```hcl +variable "aws_region" { + type = string +} + +variable "azure_location" { + type = string +} + +variable "aws_identity_token" { + type = string + ephemeral = true +} + +variable "aws_role_arn" { + type = string +} + +variable "azure_identity_token" { + type = string + ephemeral = true +} + +variable "azure_subscription_id" { + type = string +} + +variable "azure_tenant_id" { + type = string +} + +variable "azure_client_id" { + type = string +} + +variable "app_name" { + type = string +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } +} + +provider "aws" "this" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.aws_role_arn + web_identity_token = var.aws_identity_token + } + } +} + +provider "azurerm" "this" { + config { + features {} + + subscription_id = var.azure_subscription_id + tenant_id = var.azure_tenant_id + client_id = var.azure_client_id + + use_oidc = true + oidc_token = var.azure_identity_token + } +} +``` + +### components.tfcomponent.hcl +```hcl +component "aws_infrastructure" { + source = "./modules/aws-infra" + + inputs = { + region = var.aws_region + app_name = var.app_name + } + + providers = { + aws = provider.aws.this + } +} + +component "azure_infrastructure" { + source = "./modules/azure-infra" + + inputs = { + location = var.azure_location + app_name = var.app_name + } + + providers = { + azurerm = provider.azurerm.this + } +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +identity_token "azure" { + audience = ["api://AzureADTokenExchange"] +} + +deployment "multi_cloud" { + inputs = { + aws_region = "us-west-1" + azure_location = "westus2" + app_name = "my-multi-cloud-app" + aws_role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" + aws_identity_token = identity_token.aws.jwt + azure_subscription_id = "12345678-1234-1234-1234-123456789012" + azure_tenant_id = "87654321-4321-4321-4321-210987654321" + azure_client_id = "11111111-1111-1111-1111-111111111111" + azure_identity_token = identity_token.azure.jwt + } +} + +# Deployment groups +deployment_group "multi_cloud" { + deployments = [deployment.multi_cloud] +} +``` + +## Complete AWS Production Stack + +Full production-grade Stack with VPC, RDS, ECS, and monitoring. + +### variables.tfcomponent.hcl +```hcl +variable "aws_region" { + type = string + description = "AWS region" +} + +variable "environment" { + type = string + description = "Environment name" +} + +variable "vpc_cidr" { + type = string + description = "VPC CIDR block" +} + +variable "app_name" { + type = string + description = "Application name" +} + +variable "db_instance_class" { + type = string + description = "RDS instance class" +} + +variable "ecs_desired_count" { + type = number + description = "Desired ECS task count" +} + +variable "identity_token" { + type = string + ephemeral = true +} + +variable "role_arn" { + type = string +} +``` + +### providers.tfcomponent.hcl +```hcl +required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.7.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5.0" + } +} + +provider "aws" "this" { + config { + region = var.aws_region + + assume_role_with_web_identity { + role_arn = var.role_arn + web_identity_token = var.identity_token + } + + default_tags { + tags = { + Environment = var.environment + Application = var.app_name + ManagedBy = "Terraform Stacks" + } + } + } +} + +provider "random" "this" { + config {} +} +``` + +### components.tfcomponent.hcl +```hcl +locals { + name_prefix = "${var.app_name}-${var.environment}" +} + +component "vpc" { + source = "./modules/vpc" + + inputs = { + name_prefix = local.name_prefix + cidr_block = var.vpc_cidr + azs_count = 3 + } + + providers = { + aws = provider.aws.this + } +} + +component "security_groups" { + source = "./modules/security-groups" + + inputs = { + name_prefix = local.name_prefix + vpc_id = component.vpc.vpc_id + } + + providers = { + aws = provider.aws.this + } +} + +component "rds" { + source = "./modules/rds" + + inputs = { + name_prefix = local.name_prefix + instance_class = var.db_instance_class + subnet_ids = component.vpc.private_subnet_ids + security_group_ids = [component.security_groups.database_sg_id] + } + + providers = { + aws = provider.aws.this + random = provider.random.this + } +} + +component "ecs_cluster" { + source = "./modules/ecs-cluster" + + inputs = { + name_prefix = local.name_prefix + } + + providers = { + aws = provider.aws.this + } +} + +component "ecs_service" { + source = "./modules/ecs-service" + + inputs = { + name_prefix = local.name_prefix + cluster_id = component.ecs_cluster.cluster_id + desired_count = var.ecs_desired_count + subnet_ids = component.vpc.private_subnet_ids + security_group_id = component.security_groups.app_sg_id + database_endpoint = component.rds.endpoint + } + + providers = { + aws = provider.aws.this + } +} + +component "alb" { + source = "./modules/alb" + + inputs = { + name_prefix = local.name_prefix + vpc_id = component.vpc.vpc_id + subnet_ids = component.vpc.public_subnet_ids + security_group_id = component.security_groups.alb_sg_id + target_group_arn = component.ecs_service.target_group_arn + } + + providers = { + aws = provider.aws.this + } +} + +component "cloudwatch" { + source = "./modules/cloudwatch" + + inputs = { + name_prefix = local.name_prefix + cluster_name = component.ecs_cluster.cluster_name + service_name = component.ecs_service.service_name + } + + providers = { + aws = provider.aws.this + } +} +``` + +### outputs.tfcomponent.hcl +```hcl +output "load_balancer_url" { + type = string + description = "Application load balancer URL" + value = component.alb.dns_name +} + +output "database_endpoint" { + type = string + description = "RDS endpoint" + value = component.rds.endpoint + sensitive = true +} + +output "vpc_id" { + type = string + value = component.vpc.vpc_id +} + +output "ecs_cluster_name" { + type = string + value = component.ecs_cluster.cluster_name +} +``` + +### deployments.tfdeploy.hcl +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" +} + +deployment "staging" { + inputs = { + aws_region = "us-west-1" + environment = "staging" + app_name = "myapp" + vpc_cidr = "10.1.0.0/16" + db_instance_class = "db.t3.small" + ecs_desired_count = 2 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "production" { + inputs = { + aws_region = "us-west-1" + environment = "production" + app_name = "myapp" + vpc_cidr = "10.0.0.0/16" + db_instance_class = "db.r5.large" + ecs_desired_count = 5 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "staging" { + deployments = [deployment.staging] +} + +deployment_group "production" { + deployments = [deployment.production] +} + +# Auto-approve staging with safety checks +deployment_auto_approve "staging_safe" { + deployment_group = deployment_group.staging + + check { + condition = context.plan.changes.remove == 0 + reason = "Cannot auto-approve deletions in staging" + } + + check { + condition = context.plan.applyable + reason = "Plan must be applyable" + } +} +``` + +## Testing Configurations + +### Validate Stack Configuration +```bash +terraform stacks providers lock +terraform stacks validate +``` + +### Plan Specific Deployment +```bash +terraform stacks plan --deployment=development +terraform stacks plan --deployment=production +``` + +### Apply Deployment +```bash +terraform stacks apply --deployment=staging +``` + +## Destroying Deployments + +Example of safely removing a deployment from your Stack. + +### Scenario + +You want to decommission the "development" deployment while keeping staging and production active. + +### Step 1: Mark Deployment for Destruction + +Update your `deployments.tfdeploy.hcl` file to set `destroy = true`: + +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" +} + +# Mark this deployment for destruction +deployment "development" { + inputs = { + aws_region = "us-east-1" + environment = "dev" + instance_count = 1 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } + destroy = true # This tells HCP Terraform to destroy all resources +} + +# Keep these deployments active +deployment "staging" { + inputs = { + aws_region = "us-west-1" + environment = "staging" + instance_count = 2 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "production" { + inputs = { + aws_region = "us-west-1" + environment = "prod" + instance_count = 5 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "staging" { + deployments = [deployment.staging] +} + +deployment_group "production" { + deployments = [deployment.production] +} +``` + +### Step 2: Plan and Apply + +```bash +# Review the destruction plan +terraform stacks plan --deployment=development + +# Apply the destruction +terraform stacks apply --deployment=development +``` + +HCP Terraform will destroy all resources in the development deployment. + +### Step 3: Remove the Deployment Block + +After the deployment is successfully destroyed, remove the entire deployment block from your configuration: + +```hcl +identity_token "aws" { + audience = ["aws.workload.identity"] +} + +locals { + role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" +} + +# deployment "development" block has been removed + +deployment "staging" { + inputs = { + aws_region = "us-west-1" + environment = "staging" + instance_count = 2 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +deployment "production" { + inputs = { + aws_region = "us-west-1" + environment = "prod" + instance_count = 5 + role_arn = local.role_arn + identity_token = identity_token.aws.jwt + } +} + +# Deployment groups +deployment_group "staging" { + deployments = [deployment.staging] +} + +deployment_group "production" { + deployments = [deployment.production] +} +``` + +### Important Notes + +- **Provider Authentication**: The `destroy` argument ensures your configuration retains the provider authentication needed to destroy resources +- **Do Not Remove Immediately**: Don't remove the deployment block until after the destruction is complete +- **Verify Before Removing**: Check HCP Terraform UI to confirm all resources are destroyed before removing the block +- **Alternative**: You could manually destroy resources through HCP Terraform UI, but using `destroy = true` is the recommended approach for maintaining infrastructure-as-code practices diff --git a/.agents/skills/terraform-stacks/references/linked-stacks.md b/.agents/skills/terraform-stacks/references/linked-stacks.md new file mode 100644 index 0000000..a24b49a --- /dev/null +++ b/.agents/skills/terraform-stacks/references/linked-stacks.md @@ -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 "" { + type = + value = +} +``` + +### 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..` + +### 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 "" { + type = "stack" + source = "" +} +``` + +### 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///` + +### Accessing Upstream Outputs + +Reference upstream outputs using: `upstream_input..` + +### 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`. diff --git a/.agents/skills/terraform-stacks/references/troubleshooting.md b/.agents/skills/terraform-stacks/references/troubleshooting.md new file mode 100644 index 0000000..0d58262 --- /dev/null +++ b/.agents/skills/terraform-stacks/references/troubleshooting.md @@ -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:::oidc-provider/app.terraform.io" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "app.terraform.io:aud": "aws.workload.identity" + }, + "StringLike": { + "app.terraform.io:sub": "organization::project::stack::deployment:" + } + } + } + ] +} +``` + +**Azure - Verify federated credential:** +- Application ID matches the one in provider configuration +- Subject matches: `organization::project::stack::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 diff --git a/.agents/skills/terraform-style-guide/SECURITY.md b/.agents/skills/terraform-style-guide/SECURITY.md new file mode 100644 index 0000000..b95fd8b --- /dev/null +++ b/.agents/skills/terraform-style-guide/SECURITY.md @@ -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. diff --git a/.agents/skills/terraform-style-guide/SKILL.md b/.agents/skills/terraform-style-guide/SKILL.md new file mode 100644 index 0000000..a574a03 --- /dev/null +++ b/.agents/skills/terraform-style-guide/SKILL.md @@ -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)* diff --git a/.agents/skills/terraform-test/SKILL.md b/.agents/skills/terraform-test/SKILL.md new file mode 100644 index 0000000..6ce7dda --- /dev/null +++ b/.agents/skills/terraform-test/SKILL.md @@ -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) diff --git a/.agents/skills/terraform-test/references/CI_CD.md b/.agents/skills/terraform-test/references/CI_CD.md new file mode 100644 index 0000000..bace22d --- /dev/null +++ b/.agents/skills/terraform-test/references/CI_CD.md @@ -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 diff --git a/.agents/skills/terraform-test/references/EXAMPLES.md b/.agents/skills/terraform-test/references/EXAMPLES.md new file mode 100644 index 0000000..5440847 --- /dev/null +++ b/.agents/skills/terraform-test/references/EXAMPLES.md @@ -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" + } +} +``` diff --git a/.agents/skills/terraform-test/references/MOCK_PROVIDERS.md b/.agents/skills/terraform-test/references/MOCK_PROVIDERS.md new file mode 100644 index 0000000..3584c80 --- /dev/null +++ b/.agents/skills/terraform-test/references/MOCK_PROVIDERS.md @@ -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 diff --git a/.claude/skills/new-terraform-provider b/.claude/skills/new-terraform-provider new file mode 120000 index 0000000..9955460 --- /dev/null +++ b/.claude/skills/new-terraform-provider @@ -0,0 +1 @@ +../../.agents/skills/new-terraform-provider \ No newline at end of file diff --git a/.claude/skills/provider-actions b/.claude/skills/provider-actions new file mode 120000 index 0000000..209ed29 --- /dev/null +++ b/.claude/skills/provider-actions @@ -0,0 +1 @@ +../../.agents/skills/provider-actions \ No newline at end of file diff --git a/.claude/skills/provider-docs b/.claude/skills/provider-docs new file mode 120000 index 0000000..28c21ac --- /dev/null +++ b/.claude/skills/provider-docs @@ -0,0 +1 @@ +../../.agents/skills/provider-docs \ No newline at end of file diff --git a/.claude/skills/provider-resources b/.claude/skills/provider-resources new file mode 120000 index 0000000..91596c1 --- /dev/null +++ b/.claude/skills/provider-resources @@ -0,0 +1 @@ +../../.agents/skills/provider-resources \ No newline at end of file diff --git a/.claude/skills/provider-test-patterns b/.claude/skills/provider-test-patterns new file mode 120000 index 0000000..4c109cf --- /dev/null +++ b/.claude/skills/provider-test-patterns @@ -0,0 +1 @@ +../../.agents/skills/provider-test-patterns \ No newline at end of file diff --git a/.claude/skills/pulumi-terraform-to-pulumi b/.claude/skills/pulumi-terraform-to-pulumi new file mode 120000 index 0000000..4bd06cd --- /dev/null +++ b/.claude/skills/pulumi-terraform-to-pulumi @@ -0,0 +1 @@ +../../.agents/skills/pulumi-terraform-to-pulumi \ No newline at end of file diff --git a/.claude/skills/push-to-registry b/.claude/skills/push-to-registry new file mode 120000 index 0000000..267fdc6 --- /dev/null +++ b/.claude/skills/push-to-registry @@ -0,0 +1 @@ +../../.agents/skills/push-to-registry \ No newline at end of file diff --git a/.claude/skills/refactor-module b/.claude/skills/refactor-module new file mode 120000 index 0000000..de64a2d --- /dev/null +++ b/.claude/skills/refactor-module @@ -0,0 +1 @@ +../../.agents/skills/refactor-module \ No newline at end of file diff --git a/.claude/skills/run-acceptance-tests b/.claude/skills/run-acceptance-tests new file mode 120000 index 0000000..eb0d56e --- /dev/null +++ b/.claude/skills/run-acceptance-tests @@ -0,0 +1 @@ +../../.agents/skills/run-acceptance-tests \ No newline at end of file diff --git a/.claude/skills/terraform-policy b/.claude/skills/terraform-policy new file mode 120000 index 0000000..82268fe --- /dev/null +++ b/.claude/skills/terraform-policy @@ -0,0 +1 @@ +../../.agents/skills/terraform-policy \ No newline at end of file diff --git a/.claude/skills/terraform-search-import b/.claude/skills/terraform-search-import new file mode 120000 index 0000000..7845fe5 --- /dev/null +++ b/.claude/skills/terraform-search-import @@ -0,0 +1 @@ +../../.agents/skills/terraform-search-import \ No newline at end of file diff --git a/.claude/skills/terraform-stacks b/.claude/skills/terraform-stacks new file mode 120000 index 0000000..48aefb0 --- /dev/null +++ b/.claude/skills/terraform-stacks @@ -0,0 +1 @@ +../../.agents/skills/terraform-stacks \ No newline at end of file diff --git a/.claude/skills/terraform-style-guide b/.claude/skills/terraform-style-guide new file mode 120000 index 0000000..0aa301d --- /dev/null +++ b/.claude/skills/terraform-style-guide @@ -0,0 +1 @@ +../../.agents/skills/terraform-style-guide \ No newline at end of file diff --git a/.claude/skills/terraform-test b/.claude/skills/terraform-test new file mode 120000 index 0000000..87ae3bd --- /dev/null +++ b/.claude/skills/terraform-test @@ -0,0 +1 @@ +../../.agents/skills/terraform-test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a04c05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Compiled provider +terraform-provider-dokploy +dist/ + +# Terraform / OpenTofu working files +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfplan +crash.log +crash.*.log +.terraformrc +terraform.rc + +# Never commit these: they hold credentials +*.tfvars +*.tfvars.json +!example.tfvars + +# Go +vendor/ +*.test +coverage.out diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..77ea901 --- /dev/null +++ b/LICENSE @@ -0,0 +1,356 @@ +Copyright (c) 2026 Max Vojtkov + +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. “Contributor” + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. “Contributor Version” + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor’s Contribution. + +1.3. “Contribution” + + means Covered Software of a particular Contributor. + +1.4. “Covered Software” + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. “Incompatible With Secondary Licenses” + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of version + 1.1 or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. “Executable Form” + + means any form of the work other than Source Code Form. + +1.7. “Larger Work” + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + +1.8. “License” + + means this document. + +1.9. “Licensable” + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed by + this License. + +1.10. “Modifications” + + means any of the following: + + a. any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. “Patent Claims” of a Contributor + + means any patent claim(s), including without limitation, method, process, + and apparatus claims, in any patent Licensable by such Contributor that + would be infringed, but for the grant of the License, by the making, + using, selling, offering for sale, having made, import, or transfer of + either its Contributions or its Contributor Version. + +1.12. “Secondary License” + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. “Source Code Form” + + means the form of the work preferred for making modifications. + +1.14. “You” (or “Your”) + + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, “control” means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its Contributions + or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding Section + 2.1(b) above, no patent license is granted by a Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party’s + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions + are its original creation(s) or it has sufficient rights to grant the + rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this License, + or sublicense it under different terms, provided that the license for + the Executable Form does not attempt to limit or alter the recipients’ + rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations + of liability) contained within the Source Code Form of the Covered + Software, except that You may alter any license notices to the extent + required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, judicial + order, or regulation then You must: (a) comply with the terms of this License + to the maximum extent possible; and (b) describe the limitations and the code + they affect. Such description must be placed in a text file included with all + distributions of the Covered Software under this License. Except to the + extent prohibited by statute or regulation, such description must be + sufficiently detailed for a recipient of ordinary skill to be able to + understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing basis, + if such Contributor fails to notify You of the non-compliance by some + reasonable means prior to 60 days after You have come back into compliance. + Moreover, Your grants from a particular Contributor are reinstated on an + ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of + non-compliance with this License from such Contributor, and You become + compliant prior to 30 days after Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an “as is” basis, without + warranty of any kind, either expressed, implied, or statutory, including, + without limitation, warranties that the Covered Software is free of defects, + merchantable, fit for a particular purpose or non-infringing. The entire + risk as to the quality and performance of the Covered Software is with You. + Should any Covered Software prove defective in any respect, You (not any + Contributor) assume the cost of any necessary servicing, repair, or + correction. This disclaimer of warranty constitutes an essential part of this + License. No use of any Covered Software is authorized under this License + except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from such + party’s negligence to the extent applicable law prohibits such limitation. + Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts of + a jurisdiction where the defendant maintains its principal place of business + and such litigation shall be governed by laws of that jurisdiction, without + reference to its conflict-of-law provisions. Nothing in this Section shall + prevent a party’s ability to bring cross-claims or counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject matter + hereof. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable. Any law or regulation which provides that the language of a + contract shall be construed against the drafter shall not be used to construe + this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of + the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any + references to the name of the license steward (except to note that such + modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is “Incompatible + With Secondary Licenses”, as defined by + the Mozilla Public License, v. 2.0. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee84e5e --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +BINARY := terraform-provider-dokploy +VERSION ?= 0.1.0 +NAMESPACE := maxvojtkov +NAME := dokploy + +OS_ARCH := $(shell go env GOOS)_$(shell go env GOARCH) +MIRROR := $(HOME)/.local/share/terraform/plugins +DEST := $(MIRROR)/registry.terraform.io/$(NAMESPACE)/$(NAME)/$(VERSION)/$(OS_ARCH) + +.PHONY: build +build: ## Compile the provider + go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) . + +.PHONY: install +install: ## Build and install into the local filesystem mirror + @mkdir -p "$(DEST)" + go build -ldflags "-X main.version=$(VERSION)" -o "$(DEST)/$(BINARY)_v$(VERSION)" . + @echo + @echo "Installed $(BINARY) $(VERSION) to:" + @echo " $(DEST)" + @echo + @echo "Add this to ~/.terraformrc (or ~/.tofurc):" + @echo + @echo ' provider_installation {' + @echo ' filesystem_mirror {' + @echo ' path = "$(MIRROR)"' + @echo ' include = ["registry.terraform.io/$(NAMESPACE)/$(NAME)"]' + @echo ' }' + @echo ' direct {' + @echo ' exclude = ["registry.terraform.io/$(NAMESPACE)/$(NAME)"]' + @echo ' }' + @echo ' }' + +.PHONY: test +test: ## Run unit tests + go test ./... -count=1 + +.PHONY: testacc +testacc: ## Run acceptance tests against a live instance (needs DOKPLOY_HOST and DOKPLOY_API_KEY) + TF_ACC=1 go test ./internal/provider/ -v -count=1 -timeout 30m + +.PHONY: docs +docs: ## Regenerate docs/ from the provider schema + go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.23.0 generate --provider-name dokploy + +.PHONY: fmt +fmt: ## Format Go and Terraform sources + gofmt -w . + @command -v terraform >/dev/null && terraform fmt -recursive examples modules || true + +.PHONY: lint +lint: ## Vet Go sources and check formatting + go vet ./... + @test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; } + +.PHONY: clean +clean: ## Remove build artifacts + rm -f $(BINARY) + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..de71523 --- /dev/null +++ b/README.md @@ -0,0 +1,440 @@ +# terraform-provider-dokploy + +Manage [Dokploy](https://dokploy.com) as code: projects, environments, +applications, Compose stacks, managed databases, domains, mounts, ports, +redirects, basic auth, registries, SSH keys, certificates and backup +destinations. + +Works with Terraform and OpenTofu. Because it is a real Terraform provider +(built on `terraform-plugin-framework`), it can also be +[bridged into Pulumi](#using-this-from-pulumi) without rewriting anything. + +```hcl +resource "dokploy_project" "shop" { + name = "shop" +} + +resource "dokploy_postgres" "db" { + name = "shop-db" + environment_id = dokploy_project.shop.default_environment_id + docker_image = "postgres:16-alpine" + database_name = "shop" + database_user = "shop" + database_password = var.db_password +} + +resource "dokploy_application" "api" { + name = "api" + environment_id = dokploy_project.shop.default_environment_id + source_type = "docker" + docker_image = "ghcr.io/acme/api:1.4.0" + + env = "DATABASE_URL=postgresql://shop:${var.db_password}@${dokploy_postgres.db.app_name}:5432/shop" +} + +resource "dokploy_domain" "api" { + application_id = dokploy_application.api.id + host = "api.example.com" + port = 3000 + https = true + certificate_type = "letsencrypt" +} +``` + +## Contents + +- [Installing](#installing) +- [Configuring](#configuring) +- [How Dokploy's model maps to Terraform](#how-dokploys-model-maps-to-terraform) +- [Resources and data sources](#resources-and-data-sources) +- [The `web-service` module](#the-web-service-module) +- [Deployments are not managed](#deployments-are-not-managed) +- [Importing existing infrastructure](#importing-existing-infrastructure) +- [Using this from Pulumi](#using-this-from-pulumi) +- [Known API quirks](#known-api-quirks) +- [Development](#development) + +## Installing + +The provider is not published to a registry yet. Build it and install it into a +local filesystem mirror: + +```bash +make install +``` + +That writes the binary to +`~/.local/share/terraform/plugins/registry.terraform.io/maxvojtkov/dokploy/0.1.0/_/` +and prints the CLI configuration to add to `~/.terraformrc`: + +```hcl +provider_installation { + filesystem_mirror { + path = "/Users/you/.local/share/terraform/plugins" + include = ["registry.terraform.io/maxvojtkov/dokploy"] + } + direct { + exclude = ["registry.terraform.io/maxvojtkov/dokploy"] + } +} +``` + +Then in your configuration: + +```hcl +terraform { + required_providers { + dokploy = { + source = "maxvojtkov/dokploy" + version = "0.1.0" + } + } +} +``` + +## Configuring + +Generate an API token in Dokploy under **Settings → Profile → API/CLI**. + +```hcl +provider "dokploy" { + host = "https://dokploy.example.com" + api_key = var.dokploy_api_key +} +``` + +Both are better supplied through the environment, which keeps the token out of +your configuration entirely: + +| Attribute | Environment variable | Default | +| ---------------------- | ------------------------- | ------- | +| `host` | `DOKPLOY_HOST` | — | +| `api_key` | `DOKPLOY_API_KEY` | — | +| `timeout_seconds` | `DOKPLOY_TIMEOUT_SECONDS` | `60` | +| `insecure_skip_verify` | — | `false` | + +```bash +export DOKPLOY_HOST=https://dokploy.example.com +export DOKPLOY_API_KEY=... +terraform apply +``` + +## How Dokploy's model maps to Terraform + +Dokploy nests everything under a project: + +``` +project +└── environment (a default "production" one is created for you) + ├── application ├── domain, mount, port, redirect, security + ├── compose └── domain, mount + └── postgres | mysql | mariadb | mongo | redis +``` + +Two consequences worth knowing up front: + +**Every project comes with a default environment.** Creating a +`dokploy_project` also creates a `production` environment server-side. Rather +than making you import it, the provider exposes it as +`default_environment_id`: + +```hcl +resource "dokploy_project" "shop" { + name = "shop" +} + +resource "dokploy_application" "api" { + environment_id = dokploy_project.shop.default_environment_id + # ... +} +``` + +Declare `dokploy_environment` only for *additional* environments such as +staging. Declaring one named `production` would create a second environment +with the same name, not adopt the existing one. + +**Services attach to environments, not projects.** `environment_id` is always +the parent reference, and changing it forces replacement — Dokploy moves +services through a separate `move` endpoint that has no declarative equivalent. + +## Resources and data sources + +Full reference documentation lives in [`docs/`](./docs). + +### Resources + +| Resource | Purpose | +| -------------------------------------------------------- | ------------------------------------------------ | +| [`dokploy_project`](docs/resources/project.md) | Top-level container | +| [`dokploy_environment`](docs/resources/environment.md) | Additional environments within a project | +| [`dokploy_application`](docs/resources/application.md) | A service built from Git, a Docker image or an upload | +| [`dokploy_compose`](docs/resources/compose.md) | A Docker Compose or Swarm stack | +| [`dokploy_postgres`](docs/resources/postgres.md) | Managed PostgreSQL | +| [`dokploy_mysql`](docs/resources/mysql.md) | Managed MySQL | +| [`dokploy_mariadb`](docs/resources/mariadb.md) | Managed MariaDB | +| [`dokploy_mongo`](docs/resources/mongo.md) | Managed MongoDB | +| [`dokploy_redis`](docs/resources/redis.md) | Managed Redis | +| [`dokploy_domain`](docs/resources/domain.md) | A hostname routed through Traefik | +| [`dokploy_mount`](docs/resources/mount.md) | Volume, bind mount or config file | +| [`dokploy_port`](docs/resources/port.md) | A port published straight onto the host | +| [`dokploy_redirect`](docs/resources/redirect.md) | A Traefik redirect rule | +| [`dokploy_security`](docs/resources/security.md) | HTTP basic auth credentials | +| [`dokploy_registry`](docs/resources/registry.md) | A container registry | +| [`dokploy_ssh_key`](docs/resources/ssh_key.md) | SSH key pair for private Git and remote servers | +| [`dokploy_certificate`](docs/resources/certificate.md) | An uploaded TLS certificate | +| [`dokploy_destination`](docs/resources/destination.md) | S3-compatible backup destination | + +### Data sources + +| Data source | Purpose | +| ------------------------------------------------------------ | ---------------------------------------- | +| [`dokploy_project`](docs/data-sources/project.md) | Look up a project and its environments | +| [`dokploy_projects`](docs/data-sources/projects.md) | List every visible project | +| [`dokploy_environment`](docs/data-sources/environment.md) | Look up a single environment | +| [`dokploy_application`](docs/data-sources/application.md) | Look up a single application | +| [`dokploy_servers`](docs/data-sources/servers.md) | List registered remote servers | + +## The `web-service` module + +[`modules/web-service`](./modules/web-service) bundles the pieces a typical +public service needs — the application plus its domains, ports, mounts, basic +auth and redirects — behind one call: + +```hcl +module "storefront" { + source = "github.com/maxvojtkov/terraform-provider-dokploy//modules/web-service" + + name = "storefront" + environment_id = dokploy_project.shop.default_environment_id + + service_source = { + type = "github" + github_id = var.github_provider_id + owner = "acme" + repository = "storefront" + branch = "main" + } + + replicas = 2 + resources = { memory_limit = "1g", cpu_limit = "1" } + + domains = [ + { host = "shop.example.com", port = 3000 }, + ] + + mounts = [ + { mount_path = "/app/uploads", type = "volume", volume_name = "uploads" }, + ] +} +``` + +The variable is `service_source` rather than `source` because `source` is a +reserved module meta-argument in Terraform. + +See [`examples/complete`](./examples/complete) for a full environment: project, +staging environment, Postgres, Redis, two services and a Compose stack. + +## Deployments are not managed + +This provider manages *configuration*, not *rollouts*. Creating or updating a +`dokploy_application` writes its definition; it does not build or start +anything. That is deliberate — a deployment is an imperative, time-bounded +action with build logs and failure modes, not a converged state Terraform can +own. + +Trigger deployments from the Dokploy UI, from CI, or from the API: + +```bash +curl -X POST "$DOKPLOY_HOST/api/application.deploy" \ + -H "x-api-key: $DOKPLOY_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"applicationId":"'"$APP_ID"'"}' +``` + +### From CI + +[`dokploy-deploy-action`](https://github.com/maxvojtkov/dokploy-deploy-action) +is the same call with the parts that matter in a pipeline: it waits for the +build, fails the job when the deployment fails, and prints the build log. +It runs on GitHub Actions and Gitea Actions. + +```yaml +- uses: maxvojtkov/dokploy-deploy-action@v1 + with: + host: ${{ secrets.DOKPLOY_HOST }} + api-key: ${{ secrets.DOKPLOY_API_KEY }} + application-id: ${{ needs.infra.outputs.api_application_id }} + docker-image: ghcr.io/acme/api:${{ github.sha }} +``` + +Feed it this provider's `id` output and the division of labour stays clean: +Terraform owns the application's configuration, the action deploys code into +it. + +### From Terraform itself + +To couple the two, hang a `terraform_data` resource off the application so a +configuration change triggers a redeploy: + +```hcl +resource "terraform_data" "deploy_api" { + triggers_replace = [ + dokploy_application.api.docker_image, + dokploy_application.api.env, + ] + + provisioner "local-exec" { + command = <<-CMD + curl -fsS -X POST "$DOKPLOY_HOST/api/application.deploy" \ + -H "x-api-key: $DOKPLOY_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"applicationId":"${dokploy_application.api.id}"}' + CMD + } +} +``` + +Most teams are better served letting Dokploy's own Git triggers +(`auto_deploy = true`) handle rollouts and keeping Terraform to configuration. + +## Importing existing infrastructure + +Every resource imports by its Dokploy ID: + +```bash +terraform import dokploy_project.shop xK2bZpaQQH4XDEmz9CfX1 +terraform import dokploy_application.api w_CJkBMFJTmkHZHVbXrJI +terraform import dokploy_postgres.db H9o0itXlfPk8wxrRfIb8m +``` + +Find IDs through the API: + +```bash +curl -H "x-api-key: $DOKPLOY_API_KEY" "$DOKPLOY_HOST/api/project.all" \ + | jq -r '.[] | "\(.name)\t\(.projectId)"' +``` + +`dokploy_mount` is the one exception: Dokploy stores the parent as a typed +column (`applicationId`, `composeId`, …) and never echoes back the generic +`serviceId` that creation uses, so set `service_id` and `service_type` in your +configuration to match reality after importing. + +## Using this from Pulumi + +Because this is a standard Terraform provider, Pulumi consumes it through +[`pulumi-terraform-bridge`](https://github.com/pulumi/pulumi-terraform-bridge) +— no reimplementation needed. Two routes: + +**Dynamic bridge (no build step).** Point Pulumi at the provider binary and use +it straight away: + +```bash +pulumi package add terraform-provider maxvojtkov/dokploy +``` + +Pulumi generates an SDK for your language on the spot. This is the fastest path +and keeps you in lockstep with the Terraform provider. + +**Static bridge (a published SDK).** For a first-class, versioned package with +richer types, use +[`pulumi-dokploy`](https://github.com/maxvojtkov/pulumi-dokploy), which wraps +this provider and ships SDKs for TypeScript, Python, Go and .NET: + +```bash +npm install @maxvojtkov/pulumi-dokploy +pip install pulumi_dokploy +go get github.com/maxvojtkov/pulumi-dokploy/sdk/go/dokploy +dotnet add package Maxvojtkov.Dokploy +``` + +That repository holds nothing but the mapping — every resource, schema and API +call still comes from here, so the two providers move together. + +Three things are worth knowing about the translation: + +- Attribute names become camelCase in Pulumi (`environment_id` → + `environmentId`, `default_environment_id` → `defaultEnvironmentId`). +- `name` is auto-generated when omitted, following Pulumi convention. Set it + explicitly when the Dokploy-side name matters. +- The `web-service` Terraform module has no automatic Pulumi equivalent — + reimplement it as a + [ComponentResource](https://www.pulumi.com/docs/concepts/resources/components/), + which is a natural fit for the same grouping. `pulumi-dokploy` ships a port + of it in `examples/typescript/webService.ts`. + +### The `shim` package + +`internal/provider` cannot be imported from another Go module, so the bridge +goes through the small `shim` package instead: + +```go +import tfshim "github.com/maxvojtkov/terraform-provider-dokploy/shim" + +p := tfshim.NewProvider("0.1.0") // a plugin-framework provider.Provider +``` + +Keep `shim.NewProvider` stable — it is this repository's only public Go API. + +## Known API quirks + +These are properties of the Dokploy API that the provider works around; they +explain behaviour that would otherwise look surprising. + +- **`create` accepts only a subset of fields.** For applications, Compose + stacks, environments and all five databases, Dokploy's create endpoints + ignore most fields. The provider creates the resource and immediately issues + an update with the rest, so a single `apply` still converges. +- **Some endpoints return no ID.** `sshKey.create`, `redirects.create` and + `security.create` return nothing or `true`. The provider identifies the new + record by diffing the relevant list before and after the call. If several are + created outside Terraform at the same moment, this is ambiguous and the + provider reports an error rather than guessing. +- **`registry.create` performs a real `docker login`.** Invalid credentials + fail the apply with the Docker error, by design. +- **Creating a registry, SSH key or certificate needs an organization.** The + provider resolves it once per run from `user.get`. +- **`null` is not universally accepted.** Endpoints generated from the database + schema accept `null` to clear a nullable column; hand-written ones (such as + `environment.create`) reject it. The provider tracks this per field. +- **Basic auth passwords are never returned.** `dokploy_security.password` + keeps whatever you configured; drift in that one field cannot be detected. + +## Development + +```bash +make build # compile +make test # unit tests +make testacc # acceptance tests (creates and destroys real resources) +make docs # regenerate docs/ from the provider schema +make fmt lint # gofmt + go vet + terraform fmt +make install # install into the local filesystem mirror +``` + +Acceptance tests talk to a live Dokploy instance and create resources prefixed +`tfacc-`, destroying them afterwards. Point them at a scratch instance: + +```bash +TF_ACC=1 \ +DOKPLOY_HOST=https://dokploy.example.com \ +DOKPLOY_API_KEY=... \ +make testacc +``` + +They require the `terraform` binary; `terraform-plugin-testing` does not fully +support OpenTofu's provider addressing. The provider itself works with both. + +### Layout + +``` +internal/client/ HTTP client for /api/. +internal/tfmap/ reflection-based mapping between models and Dokploy JSON +internal/provider/ provider, resources, data sources +modules/web-service/ reusable module +examples/complete/ worked example +docs/ generated reference documentation +``` + +Resources are declared as `ResourceSpec` values — the procedures to call plus a +model struct whose `dokploy:"..."` tags describe how each field is serialized — +and a single generic implementation provides CRUD and import for all of them. +Adding a resource means writing a model and a spec, not another CRUD loop. diff --git a/docs/data-sources/application.md b/docs/data-sources/application.md new file mode 100644 index 0000000..c7c1e64 --- /dev/null +++ b/docs/data-sources/application.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_application Data Source - dokploy" +subcategory: "" +description: |- + Look up an existing application by ID. +--- + +# dokploy_application (Data Source) + +Look up an existing application by ID. + + + + +## Schema + +### Required + +- `id` (String) Application identifier. + +### Read-Only + +- `app_name` (String) Docker service name. +- `application_status` (String) Current status. +- `build_type` (String) Build strategy. +- `created_at` (String) Creation timestamp. +- `description` (String) Application description. +- `environment_id` (String) Owning environment. +- `name` (String) Application name. +- `source_type` (String) Source of the code or image. diff --git a/docs/data-sources/environment.md b/docs/data-sources/environment.md new file mode 100644 index 0000000..4c57ebf --- /dev/null +++ b/docs/data-sources/environment.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_environment Data Source - dokploy" +subcategory: "" +description: |- + Look up an existing environment by ID. +--- + +# dokploy_environment (Data Source) + +Look up an existing environment by ID. + + + + +## Schema + +### Required + +- `id` (String) Environment identifier. + +### Read-Only + +- `created_at` (String) Creation timestamp. +- `description` (String) Environment description. +- `env` (String) Environment-wide variables. +- `is_default` (Boolean) Whether this is the project's default environment. +- `name` (String) Environment name. +- `project_id` (String) Owning project. diff --git a/docs/data-sources/project.md b/docs/data-sources/project.md new file mode 100644 index 0000000..2017c10 --- /dev/null +++ b/docs/data-sources/project.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_project Data Source - dokploy" +subcategory: "" +description: |- + Look up an existing Dokploy project by ID, including its environments. +--- + +# dokploy_project (Data Source) + +Look up an existing Dokploy project by ID, including its environments. + + + + +## Schema + +### Required + +- `id` (String) Project identifier. + +### Read-Only + +- `created_at` (String) Creation timestamp. +- `default_environment_id` (String) ID of the project's default environment. +- `description` (String) Project description. +- `env` (String) Project-wide environment variables. +- `environments` (List of Object) Every environment in the project. (see [below for nested schema](#nestedatt--environments)) +- `name` (String) Project name. +- `organization_id` (String) Owning organization. + + +### Nested Schema for `environments` + +Read-Only: + +- `id` (String) +- `is_default` (Boolean) +- `name` (String) diff --git a/docs/data-sources/projects.md b/docs/data-sources/projects.md new file mode 100644 index 0000000..b6d73af --- /dev/null +++ b/docs/data-sources/projects.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_projects Data Source - dokploy" +subcategory: "" +description: |- + List every Dokploy project the API token can see. +--- + +# dokploy_projects (Data Source) + +List every Dokploy project the API token can see. + + + + +## Schema + +### Read-Only + +- `projects` (List of Object) All visible projects. (see [below for nested schema](#nestedatt--projects)) + + +### Nested Schema for `projects` + +Read-Only: + +- `default_environment_id` (String) +- `description` (String) +- `id` (String) +- `name` (String) diff --git a/docs/data-sources/servers.md b/docs/data-sources/servers.md new file mode 100644 index 0000000..b8b07ed --- /dev/null +++ b/docs/data-sources/servers.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_servers Data Source - dokploy" +subcategory: "" +description: |- + List the remote servers registered with Dokploy. Use a server's id as the server_id of an application, stack, or database to deploy it away from the Dokploy host. +--- + +# dokploy_servers (Data Source) + +List the remote servers registered with Dokploy. Use a server's `id` as the `server_id` of an application, stack, or database to deploy it away from the Dokploy host. + + + + +## Schema + +### Read-Only + +- `servers` (List of Object) All registered servers. (see [below for nested schema](#nestedatt--servers)) + + +### Nested Schema for `servers` + +Read-Only: + +- `id` (String) +- `ip_address` (String) +- `name` (String) +- `server_status` (String) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c27873e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy Provider" +description: |- + Manage Dokploy https://dokploy.com projects, environments, applications, compose stacks, databases and networking with Terraform. +--- + +# dokploy Provider + +Manage [Dokploy](https://dokploy.com) projects, environments, applications, compose stacks, databases and networking with Terraform. + + + + +## Schema + +### Optional + +- `api_key` (String, Sensitive) API token generated in Dokploy under *Settings -> Profile -> API/CLI*. May also be set with the `DOKPLOY_API_KEY` environment variable. +- `host` (String) Base URL of the Dokploy instance, for example `https://dokploy.example.com`. A trailing `/api` is optional. May also be set with the `DOKPLOY_HOST` environment variable. +- `insecure_skip_verify` (Boolean) Skip TLS certificate verification. Only use this for instances behind a self-signed certificate. Defaults to `false`. +- `timeout_seconds` (Number) Per-request timeout in seconds. Defaults to `60`. May also be set with the `DOKPLOY_TIMEOUT_SECONDS` environment variable. diff --git a/docs/resources/application.md b/docs/resources/application.md new file mode 100644 index 0000000..6c48ae1 --- /dev/null +++ b/docs/resources/application.md @@ -0,0 +1,128 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_application Resource - dokploy" +subcategory: "" +description: |- + A Dokploy application: a single service built from a Git repository, a Docker image, or an uploaded artifact. + Dokploy's application.create endpoint accepts only a handful of fields, so this resource creates the application and then applies the rest of the configuration through application.update. + ~> Creating this resource does not deploy the application. Trigger a deployment from the Dokploy UI, the CLI, or a terraform_data provisioner calling application.deploy. +--- + +# dokploy_application (Resource) + +A Dokploy application: a single service built from a Git repository, a Docker image, or an uploaded artifact. + +Dokploy's `application.create` endpoint accepts only a handful of fields, so this resource creates the application and then applies the rest of the configuration through `application.update`. + +~> Creating this resource does **not** deploy the application. Trigger a deployment from the Dokploy UI, the CLI, or a `terraform_data` provisioner calling `application.deploy`. + + + + +## Schema + +### Required + +- `environment_id` (String) Environment this application belongs to. +- `name` (String) Display name of the application. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new application. +- `args` (List of String) Arguments appended to the container command. +- `auto_deploy` (Boolean) Deploy automatically when the configured trigger fires. +- `bitbucket_branch` (String) Bitbucket branch to build. +- `bitbucket_build_path` (String) Path within the Bitbucket repository to build from. +- `bitbucket_id` (String) ID of the configured Bitbucket provider connection. +- `bitbucket_owner` (String) Bitbucket repository owner. +- `bitbucket_repository` (String) Bitbucket repository name. +- `bitbucket_repository_slug` (String) Bitbucket repository slug. +- `branch` (String) GitHub branch to build. +- `build_args` (String) Docker build arguments in `KEY=value` format, one per line. +- `build_path` (String) Path within the GitHub repository to build from. +- `build_registry_id` (String) Registry used by a dedicated build server. +- `build_secrets` (String, Sensitive) Docker build secrets in `KEY=value` format, one per line. +- `build_server_id` (String) Server that performs builds, when separate from the deploy server. +- `build_type` (String) How the application is built. Valid values: `dockerfile`, `heroku_buildpacks`, `paketo_buildpacks`, `nixpacks`, `static`, `railpack`. +- `clean_cache` (Boolean) Discard the build cache on the next deployment. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `create_env_file` (Boolean) Write the environment variables to a `.env` file in the container. +- `custom_git_branch` (String) Branch to build for a custom Git remote. +- `custom_git_build_path` (String) Path within a custom Git repository to build from. +- `custom_git_ssh_key_id` (String) SSH key used to clone a private custom Git remote. +- `custom_git_url` (String) Git remote URL, when `source_type` is `git`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `docker_build_stage` (String) Target stage for a multi-stage Docker build. +- `docker_context_path` (String) Docker build context path. +- `docker_image` (String) Docker image reference, when `source_type` is `docker`. +- `dockerfile` (String) Path to the Dockerfile, when `build_type` is `dockerfile`. +- `enable_submodules` (Boolean) Clone Git submodules when checking out the repository. +- `enabled` (Boolean) Whether the application is enabled. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Runtime environment variables in `KEY=value` format, one per line. +- `gitea_branch` (String) Gitea branch to build. +- `gitea_build_path` (String) Path within the Gitea repository to build from. +- `gitea_id` (String) ID of the configured Gitea provider connection. +- `gitea_owner` (String) Gitea repository owner. +- `gitea_repository` (String) Gitea repository name. +- `github_id` (String) ID of the configured GitHub provider connection. +- `gitlab_branch` (String) GitLab branch to build. +- `gitlab_build_path` (String) Path within the GitLab repository to build from. +- `gitlab_id` (String) ID of the configured GitLab provider connection. +- `gitlab_owner` (String) GitLab repository owner. +- `gitlab_path_namespace` (String) Full GitLab namespace path. +- `gitlab_project_id` (Number) Numeric GitLab project ID. +- `gitlab_repository` (String) GitLab repository name. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `heroku_version` (String) Heroku buildpack stack version. +- `is_preview_deployments_active` (Boolean) Build a preview deployment for each pull request. +- `is_static_spa` (Boolean) Serve a static build as a single-page application. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `owner` (String) GitHub repository owner. +- `password` (String, Sensitive) Registry password, when pulling a private image. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `preview_build_args` (String) Build arguments applied to preview deployments. +- `preview_build_secrets` (String, Sensitive) Build secrets applied to preview deployments. +- `preview_certificate_type` (String) Certificate strategy for preview deployments. Valid values: `letsencrypt`, `none`, `custom`. +- `preview_custom_cert_resolver` (String) Traefik certificate resolver for preview deployments. +- `preview_env` (String) Environment variables applied to preview deployments. +- `preview_https` (Boolean) Serve preview deployments over HTTPS. +- `preview_labels` (List of String) Pull request labels that opt into preview deployments. +- `preview_limit` (Number) Maximum number of concurrent preview deployments. +- `preview_path` (String) Base path for preview deployments. +- `preview_port` (Number) Container port exposed by preview deployments. +- `preview_wildcard` (String) Wildcard domain used to expose preview deployments. +- `publish_directory` (String) Directory served when `build_type` is `static`. +- `railpack_version` (String) Railpack version. +- `registry_id` (String) Registry used to push the built image. +- `registry_url` (String) Registry URL, when pulling a private image. +- `replicas` (Number) Number of replicas to run. +- `repository` (String) GitHub repository name. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_active` (Boolean) Keep previous images so deployments can be rolled back. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `rollback_registry_id` (String) Registry used to store rollback images. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `source_type` (String) Where the application's code or image comes from. Valid values: `docker`, `git`, `github`, `gitlab`, `bitbucket`, `gitea`, `drop`. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `subtitle` (String) Display subtitle shown in the Dokploy UI. +- `title` (String) Display title shown in the Dokploy UI. +- `trigger_type` (String) What triggers an automatic GitHub deployment. Valid values: `push`, `tag`. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. +- `username` (String) Registry username, when pulling a private image. +- `watch_paths` (List of String) Glob patterns that limit which changed paths trigger an automatic deployment. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the application was created. +- `id` (String) Unique application identifier. diff --git a/docs/resources/certificate.md b/docs/resources/certificate.md new file mode 100644 index 0000000..27a18c0 --- /dev/null +++ b/docs/resources/certificate.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_certificate Resource - dokploy" +subcategory: "" +description: |- + A TLS certificate uploaded to Dokploy, for domains that use certificate_type = "custom". + ~> The private key is stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_certificate (Resource) + +A TLS certificate uploaded to Dokploy, for domains that use `certificate_type = "custom"`. + +~> The private key is stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `certificate_data` (String) PEM-encoded certificate chain. +- `name` (String) Display name of the certificate. +- `private_key` (String, Sensitive) PEM-encoded private key. + +### Optional + +- `auto_renew` (Boolean) Whether Dokploy should renew this certificate automatically. +- `server_id` (String) Server this certificate is installed on. + +### Read-Only + +- `certificate_path` (String) Path where Dokploy writes the certificate on disk. +- `id` (String) Unique certificate identifier. diff --git a/docs/resources/compose.md b/docs/resources/compose.md new file mode 100644 index 0000000..1c0c0eb --- /dev/null +++ b/docs/resources/compose.md @@ -0,0 +1,76 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_compose Resource - dokploy" +subcategory: "" +description: |- + A Docker Compose or Docker Swarm stack managed by Dokploy. + Set compose_file to manage the stack definition inline (with source_type = "raw"), or point the stack at a Git repository and set compose_path. + ~> Creating this resource does not deploy the stack. +--- + +# dokploy_compose (Resource) + +A Docker Compose or Docker Swarm stack managed by Dokploy. + +Set `compose_file` to manage the stack definition inline (with `source_type = "raw"`), or point the stack at a Git repository and set `compose_path`. + +~> Creating this resource does **not** deploy the stack. + + + + +## Schema + +### Required + +- `environment_id` (String) Environment this stack belongs to. +- `name` (String) Display name of the stack. + +### Optional + +- `app_name` (String) Unique Docker stack name. Generated by Dokploy when omitted. Changing it forces a new stack. +- `auto_deploy` (Boolean) Deploy automatically when the configured trigger fires. +- `bitbucket_branch` (String) Bitbucket branch to deploy. +- `bitbucket_id` (String) ID of the configured Bitbucket provider connection. +- `bitbucket_owner` (String) Bitbucket repository owner. +- `bitbucket_repository` (String) Bitbucket repository name. +- `bitbucket_repository_slug` (String) Bitbucket repository slug. +- `branch` (String) GitHub branch to deploy. +- `command` (String) Custom `docker compose` command to run. +- `compose_file` (String) Inline Compose file contents. Used when `source_type` is `raw`. +- `compose_path` (String) Path to the Compose file within the repository. +- `compose_type` (String) Whether to run the stack with Docker Compose or Docker Swarm. Valid values: `docker-compose`, `stack`. +- `custom_git_branch` (String) Branch to deploy for a custom Git remote. +- `custom_git_ssh_key_id` (String) SSH key used to clone a private custom Git remote. +- `custom_git_url` (String) Git remote URL, when `source_type` is `git`. +- `delete_volumes` (Boolean) Whether to delete the stack's Docker volumes when this resource is destroyed. Defaults to `false`, which preserves the data. +- `description` (String) Free-form description. +- `enable_submodules` (Boolean) Clone Git submodules when checking out the repository. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `gitea_branch` (String) Gitea branch to deploy. +- `gitea_id` (String) ID of the configured Gitea provider connection. +- `gitea_owner` (String) Gitea repository owner. +- `gitea_repository` (String) Gitea repository name. +- `github_id` (String) ID of the configured GitHub provider connection. +- `gitlab_branch` (String) GitLab branch to deploy. +- `gitlab_id` (String) ID of the configured GitLab provider connection. +- `gitlab_owner` (String) GitLab repository owner. +- `gitlab_path_namespace` (String) Full GitLab namespace path. +- `gitlab_project_id` (Number) Numeric GitLab project ID. +- `gitlab_repository` (String) GitLab repository name. +- `isolated_deployment` (Boolean) Run the stack on its own isolated Docker network. +- `isolated_deployments_volume` (Boolean) Prefix volume names for isolated deployments. Retained for backwards compatibility. +- `owner` (String) GitHub repository owner. +- `randomize` (Boolean) Append a random suffix to service and volume names. +- `repository` (String) GitHub repository name. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `source_type` (String) Where the Compose file comes from. Valid values: `git`, `github`, `gitlab`, `bitbucket`, `gitea`, `raw`. +- `suffix` (String) Suffix appended to generated resource names. +- `trigger_type` (String) What triggers an automatic deployment. Valid values: `push`, `tag`. +- `watch_paths` (List of String) Glob patterns that limit which changed paths trigger an automatic deployment. + +### Read-Only + +- `compose_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the stack was created. +- `id` (String) Unique compose identifier. diff --git a/docs/resources/destination.md b/docs/resources/destination.md new file mode 100644 index 0000000..fde8c9a --- /dev/null +++ b/docs/resources/destination.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_destination Resource - dokploy" +subcategory: "" +description: |- + An S3-compatible bucket that Dokploy writes database and volume backups to. + ~> The secret access key is stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_destination (Resource) + +An S3-compatible bucket that Dokploy writes database and volume backups to. + +~> The secret access key is stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `access_key` (String) S3 access key ID. +- `bucket` (String) Bucket name. +- `endpoint` (String) S3 endpoint URL. +- `name` (String) Display name of the destination. +- `region` (String) Bucket region, for example `us-east-1`. +- `secret_access_key` (String, Sensitive) S3 secret access key. + +### Optional + +- `additional_flags` (List of String) Extra flags passed to the underlying `rclone` invocation. +- `provider_name` (String) Provider label, for example `s3` or `cloudflare`. Named `provider_name` because `provider` is reserved by Terraform. +- `server_id` (String) Server this destination is scoped to. + +### Read-Only + +- `created_at` (String) Timestamp of when the destination was created. +- `id` (String) Unique destination identifier. diff --git a/docs/resources/domain.md b/docs/resources/domain.md new file mode 100644 index 0000000..4dc31f8 --- /dev/null +++ b/docs/resources/domain.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_domain Resource - dokploy" +subcategory: "" +description: |- + A domain routed to an application or a Compose service through Dokploy's Traefik instance. + Set exactly one of application_id or compose_id. When targeting a Compose stack, service_name selects which service in the stack receives the traffic. +--- + +# dokploy_domain (Resource) + +A domain routed to an application or a Compose service through Dokploy's Traefik instance. + +Set exactly one of `application_id` or `compose_id`. When targeting a Compose stack, `service_name` selects which service in the stack receives the traffic. + + + + +## Schema + +### Required + +- `host` (String) Fully-qualified hostname, for example `api.example.com`. + +### Optional + +- `application_id` (String) Application this domain routes to. +- `certificate_type` (String) How TLS certificates are obtained. Use `letsencrypt` for automatic certificates. Valid values: `letsencrypt`, `none`, `custom`. +- `compose_id` (String) Compose stack this domain routes to. +- `custom_cert_resolver` (String) Traefik certificate resolver name, when `certificate_type` is `custom`. +- `custom_entrypoint` (String) Traefik entrypoint to bind, when not using the defaults. +- `domain_type` (String) What kind of target this domain points at. Valid values: `compose`, `application`, `preview`. +- `forward_auth_enabled` (Boolean) Protect this domain with Dokploy's forward auth. +- `https` (Boolean) Serve the domain over HTTPS and redirect HTTP traffic to it. +- `internal_path` (String) Path the request is rewritten to before it reaches the container, defaults to `/`. +- `middlewares` (List of String) Names of Traefik middlewares to apply. +- `path` (String) Path prefix this domain routes, defaults to `/`. +- `port` (Number) Container port that receives the traffic, defaults to `3000`. +- `preview_deployment_id` (String) Preview deployment this domain routes to. +- `service_name` (String) Name of the service inside a Compose stack that receives the traffic. Required when `compose_id` is set. +- `strip_path` (Boolean) Strip `path` from the request before forwarding it. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the domain was created. +- `id` (String) Unique domain identifier. diff --git a/docs/resources/environment.md b/docs/resources/environment.md new file mode 100644 index 0000000..d881e02 --- /dev/null +++ b/docs/resources/environment.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_environment Resource - dokploy" +subcategory: "" +description: |- + An environment inside a Dokploy project, such as staging or production. Services belong to an environment rather than directly to a project. + Dokploy creates a default production environment with every project; reference it via dokploy_project..default_environment_id instead of declaring it here. +--- + +# dokploy_environment (Resource) + +An environment inside a Dokploy project, such as `staging` or `production`. Services belong to an environment rather than directly to a project. + +Dokploy creates a default `production` environment with every project; reference it via `dokploy_project..default_environment_id` instead of declaring it here. + + + + +## Schema + +### Required + +- `name` (String) Environment name, for example `staging`. +- `project_id` (String) Project this environment belongs to. + +### Optional + +- `description` (String) Free-form description. +- `env` (String) Environment-wide variables in `KEY=value` format, one per line. These are merged into every service in this environment. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the environment was created. +- `id` (String) Unique environment identifier. +- `is_default` (Boolean) Whether this is the project's default environment. diff --git a/docs/resources/mariadb.md b/docs/resources/mariadb.md new file mode 100644 index 0000000..f814604 --- /dev/null +++ b/docs/resources/mariadb.md @@ -0,0 +1,66 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_mariadb Resource - dokploy" +subcategory: "" +description: |- + A managed MariaDB instance running on Dokploy. + ~> Creating this resource provisions the service definition but does not start a deployment. Deploy it from the Dokploy UI or CLI. + ~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_mariadb (Resource) + +A managed MariaDB instance running on Dokploy. + +~> Creating this resource provisions the service definition but does **not** start a deployment. Deploy it from the Dokploy UI or CLI. + +~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `database_name` (String) Name of the database to create. +- `database_password` (String, Sensitive) Password for the database user. +- `database_root_password` (String, Sensitive) Password for the MariaDB `root` user. +- `database_user` (String) Database user to create. +- `docker_image` (String) MariaDB image to run, for example `mariadb:11`. +- `environment_id` (String) Environment this database belongs to. +- `name` (String) Display name of the database. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new database. +- `args` (List of String) Arguments appended to the container command. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `external_port` (Number) Host port to expose the database on. Leave unset to keep the database reachable only from inside the Docker network. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `replicas` (Number) Number of replicas to run. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the database was created. +- `id` (String) Unique mariadb identifier. diff --git a/docs/resources/mongo.md b/docs/resources/mongo.md new file mode 100644 index 0000000..5c5d505 --- /dev/null +++ b/docs/resources/mongo.md @@ -0,0 +1,65 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_mongo Resource - dokploy" +subcategory: "" +description: |- + A managed MongoDB instance running on Dokploy. + ~> Creating this resource provisions the service definition but does not start a deployment. Deploy it from the Dokploy UI or CLI. + ~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_mongo (Resource) + +A managed MongoDB instance running on Dokploy. + +~> Creating this resource provisions the service definition but does **not** start a deployment. Deploy it from the Dokploy UI or CLI. + +~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `database_password` (String, Sensitive) Password for the root user. +- `database_user` (String) Root username to create. +- `environment_id` (String) Environment this database belongs to. +- `name` (String) Display name of the database. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new database. +- `args` (List of String) Arguments appended to the container command. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `docker_image` (String) MongoDB image to run. Defaults to `mongo:8`. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `external_port` (Number) Host port to expose the database on. Leave unset to keep the database reachable only from inside the Docker network. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `replica_sets` (Boolean) Start the instance as a single-node replica set. +- `replicas` (Number) Number of replicas to run. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the database was created. +- `id` (String) Unique mongo identifier. diff --git a/docs/resources/mount.md b/docs/resources/mount.md new file mode 100644 index 0000000..ff1b968 --- /dev/null +++ b/docs/resources/mount.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_mount Resource - dokploy" +subcategory: "" +description: |- + A volume, bind mount, or config file attached to a Dokploy service. + type = "volume" — a named Docker volume; set volume_name.type = "bind" — a path on the host; set host_path.type = "file" — a file rendered from content; set file_path. +--- + +# dokploy_mount (Resource) + +A volume, bind mount, or config file attached to a Dokploy service. + +* `type = "volume"` — a named Docker volume; set `volume_name`. +* `type = "bind"` — a path on the host; set `host_path`. +* `type = "file"` — a file rendered from `content`; set `file_path`. + + + + +## Schema + +### Required + +- `mount_path` (String) Path inside the container where the mount appears. +- `service_id` (String) ID of the service this mount attaches to. Must match `service_type` — an application ID, a compose ID, a postgres ID, and so on. +- `service_type` (String) The kind of service this mount attaches to. Valid values: `application`, `postgres`, `mysql`, `mariadb`, `mongo`, `redis`, `compose`. +- `type` (String) The kind of mount to create. Valid values: `bind`, `volume`, `file`. + +### Optional + +- `content` (String) Contents of the generated file, when `type` is `file`. +- `file_path` (String) Path of the generated file, when `type` is `file`. +- `host_path` (String) Path on the host, when `type` is `bind`. +- `volume_name` (String) Name of the Docker volume, when `type` is `volume`. + +### Read-Only + +- `id` (String) Unique mount identifier. diff --git a/docs/resources/mysql.md b/docs/resources/mysql.md new file mode 100644 index 0000000..0c9db96 --- /dev/null +++ b/docs/resources/mysql.md @@ -0,0 +1,66 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_mysql Resource - dokploy" +subcategory: "" +description: |- + A managed MySQL instance running on Dokploy. + ~> Creating this resource provisions the service definition but does not start a deployment. Deploy it from the Dokploy UI or CLI. + ~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_mysql (Resource) + +A managed MySQL instance running on Dokploy. + +~> Creating this resource provisions the service definition but does **not** start a deployment. Deploy it from the Dokploy UI or CLI. + +~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `database_name` (String) Name of the database to create. +- `database_password` (String, Sensitive) Password for the database user. +- `database_root_password` (String, Sensitive) Password for the MySQL `root` user. +- `database_user` (String) Database user to create. +- `docker_image` (String) MySQL image to run, for example `mysql:8`. +- `environment_id` (String) Environment this database belongs to. +- `name` (String) Display name of the database. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new database. +- `args` (List of String) Arguments appended to the container command. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `external_port` (Number) Host port to expose the database on. Leave unset to keep the database reachable only from inside the Docker network. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `replicas` (Number) Number of replicas to run. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the database was created. +- `id` (String) Unique mysql identifier. diff --git a/docs/resources/port.md b/docs/resources/port.md new file mode 100644 index 0000000..4981f9a --- /dev/null +++ b/docs/resources/port.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_port Resource - dokploy" +subcategory: "" +description: |- + A published port that exposes an application directly on the host, bypassing Traefik. +--- + +# dokploy_port (Resource) + +A published port that exposes an application directly on the host, bypassing Traefik. + + + + +## Schema + +### Required + +- `application_id` (String) Application this port belongs to. +- `protocol` (String) Transport protocol. Valid values: `tcp`, `udp`. +- `published_port` (Number) Port exposed on the host. +- `target_port` (Number) Port the container listens on. + +### Optional + +- `publish_mode` (String) Docker Swarm publish mode. `host` binds directly to the node; `ingress` uses the swarm routing mesh. Valid values: `ingress`, `host`. + +### Read-Only + +- `id` (String) Unique port identifier. diff --git a/docs/resources/postgres.md b/docs/resources/postgres.md new file mode 100644 index 0000000..eb2641f --- /dev/null +++ b/docs/resources/postgres.md @@ -0,0 +1,65 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_postgres Resource - dokploy" +subcategory: "" +description: |- + A managed PostgreSQL instance running on Dokploy. + ~> Creating this resource provisions the service definition but does not start a deployment. Deploy it from the Dokploy UI or CLI. + ~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_postgres (Resource) + +A managed PostgreSQL instance running on Dokploy. + +~> Creating this resource provisions the service definition but does **not** start a deployment. Deploy it from the Dokploy UI or CLI. + +~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `database_name` (String) Name of the database to create. +- `database_password` (String, Sensitive) Password for the database user. +- `database_user` (String) Database user to create. +- `docker_image` (String) PostgreSQL image to run, for example `postgres:16`. +- `environment_id` (String) Environment this database belongs to. +- `name` (String) Display name of the database. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new database. +- `args` (List of String) Arguments appended to the container command. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `external_port` (Number) Host port to expose the database on. Leave unset to keep the database reachable only from inside the Docker network. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `replicas` (Number) Number of replicas to run. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the database was created. +- `id` (String) Unique postgres identifier. diff --git a/docs/resources/project.md b/docs/resources/project.md new file mode 100644 index 0000000..92f85ae --- /dev/null +++ b/docs/resources/project.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_project Resource - dokploy" +subcategory: "" +description: |- + A Dokploy project: the top-level container for environments and services. + Creating a project automatically creates a default production environment. Its ID is exposed as default_environment_id, so services can be attached without declaring a separate dokploy_environment resource. +--- + +# dokploy_project (Resource) + +A Dokploy project: the top-level container for environments and services. + +Creating a project automatically creates a default `production` environment. Its ID is exposed as `default_environment_id`, so services can be attached without declaring a separate `dokploy_environment` resource. + + + + +## Schema + +### Required + +- `name` (String) Display name of the project. + +### Optional + +- `description` (String) Free-form description. +- `env` (String) Project-wide environment variables in `KEY=value` format, one per line. These are shared with every service in the project. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the project was created. +- `default_environment_id` (String) ID of the `production` environment that Dokploy creates automatically with the project. +- `id` (String) Unique project identifier. +- `organization_id` (String) Organization that owns the project. diff --git a/docs/resources/redirect.md b/docs/resources/redirect.md new file mode 100644 index 0000000..5872224 --- /dev/null +++ b/docs/resources/redirect.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_redirect Resource - dokploy" +subcategory: "" +description: |- + A Traefik redirect rule attached to an application. +--- + +# dokploy_redirect (Resource) + +A Traefik redirect rule attached to an application. + + + + +## Schema + +### Required + +- `application_id` (String) Application this redirect belongs to. +- `regex` (String) Regular expression matched against the incoming URL. +- `replacement` (String) Replacement URL, which may reference capture groups such as `${1}`. + +### Optional + +- `permanent` (Boolean) Issue a permanent (301) redirect instead of a temporary (302) one. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the redirect was created. +- `id` (String) Unique redirect identifier. diff --git a/docs/resources/redis.md b/docs/resources/redis.md new file mode 100644 index 0000000..a6b5d58 --- /dev/null +++ b/docs/resources/redis.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_redis Resource - dokploy" +subcategory: "" +description: |- + A managed Redis instance running on Dokploy. + ~> Creating this resource provisions the service definition but does not start a deployment. Deploy it from the Dokploy UI or CLI. + ~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. +--- + +# dokploy_redis (Resource) + +A managed Redis instance running on Dokploy. + +~> Creating this resource provisions the service definition but does **not** start a deployment. Deploy it from the Dokploy UI or CLI. + +~> Credentials are stored in Terraform state. Use a state backend with encryption at rest. + + + + +## Schema + +### Required + +- `database_password` (String, Sensitive) Password used to authenticate to Redis. +- `docker_image` (String) Redis image to run, for example `redis:7`. +- `environment_id` (String) Environment this database belongs to. +- `name` (String) Display name of the database. + +### Optional + +- `app_name` (String) Unique Docker service name. Generated by Dokploy when omitted. Changing it forces a new database. +- `args` (List of String) Arguments appended to the container command. +- `command` (String) Override the container entrypoint command. +- `cpu_limit` (String) Hard CPU limit, for example `1`. +- `cpu_reservation` (String) Soft CPU reservation, for example `0.5`. +- `description` (String) Free-form description. +- `detach_dokploy_network` (Boolean) Detach the service from the shared `dokploy-network`. +- `endpoint_spec_swarm` (String) Docker Swarm endpoint specification, as a JSON object. +- `env` (String) Environment variables in `KEY=value` format, one per line. +- `external_port` (Number) Host port to expose the database on. Leave unset to keep the database reachable only from inside the Docker network. +- `health_check_swarm` (String) Docker Swarm health check configuration, as a JSON object. +- `labels_swarm` (String) Docker Swarm service labels, as a JSON object. +- `memory_limit` (String) Hard memory limit, for example `512m`. +- `memory_reservation` (String) Soft memory reservation, for example `256m`. +- `mode_swarm` (String) Docker Swarm service mode, as a JSON object. +- `network_ids` (List of String) IDs of additional Docker networks to attach. +- `network_swarm` (String) Docker Swarm network attachments, as a JSON array. +- `placement_swarm` (String) Docker Swarm placement constraints, as a JSON object. +- `replicas` (Number) Number of replicas to run. +- `restart_policy_swarm` (String) Docker Swarm restart policy, as a JSON object. +- `rollback_config_swarm` (String) Docker Swarm rollback configuration, as a JSON object. +- `server_id` (String) Remote server to deploy on. Omit to use the Dokploy host itself. +- `stop_grace_period_swarm` (Number) Grace period in nanoseconds before a container is killed. +- `ulimits_swarm` (String) Docker Swarm ulimits, as a JSON object. +- `update_config_swarm` (String) Docker Swarm rolling update configuration, as a JSON object. + +### Read-Only + +- `application_status` (String) Current status reported by Dokploy: `idle`, `running`, `done` or `error`. +- `created_at` (String) RFC 3339 timestamp of when the database was created. +- `id` (String) Unique redis identifier. diff --git a/docs/resources/registry.md b/docs/resources/registry.md new file mode 100644 index 0000000..46c17a3 --- /dev/null +++ b/docs/resources/registry.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_registry Resource - dokploy" +subcategory: "" +description: |- + A container registry that Dokploy pushes built images to and pulls them from. +--- + +# dokploy_registry (Resource) + +A container registry that Dokploy pushes built images to and pulls them from. + + + + +## Schema + +### Required + +- `password` (String, Sensitive) Password or access token used to authenticate. +- `registry_name` (String) Display name of the registry. +- `registry_url` (String) Registry hostname, for example `ghcr.io`. +- `username` (String) Username used to authenticate to the registry. + +### Optional + +- `image_prefix` (String) Prefix prepended to pushed image names, for example an organization or namespace. +- `registry_type` (String) Registry kind. Dokploy currently accepts only `cloud`. Valid values: `cloud`. Defaults to `cloud`. +- `server_id` (String) Server this registry is scoped to. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the registry was created. +- `id` (String) Unique registry identifier. diff --git a/docs/resources/security.md b/docs/resources/security.md new file mode 100644 index 0000000..2b8a2c8 --- /dev/null +++ b/docs/resources/security.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_security Resource - dokploy" +subcategory: "" +description: |- + HTTP basic authentication credentials protecting an application's domains. + ~> Dokploy stores the password hashed and does not return it. The value in Terraform state is the one you configured. +--- + +# dokploy_security (Resource) + +HTTP basic authentication credentials protecting an application's domains. + +~> Dokploy stores the password hashed and does not return it. The value in Terraform state is the one you configured. + + + + +## Schema + +### Required + +- `application_id` (String) Application these credentials protect. +- `password` (String, Sensitive) Basic auth password. +- `username` (String) Basic auth username. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the credentials were created. +- `id` (String) Unique credential identifier. diff --git a/docs/resources/ssh_key.md b/docs/resources/ssh_key.md new file mode 100644 index 0000000..f5c6b84 --- /dev/null +++ b/docs/resources/ssh_key.md @@ -0,0 +1,38 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dokploy_ssh_key Resource - dokploy" +subcategory: "" +description: |- + An SSH key pair Dokploy uses to clone private Git repositories and to reach remote servers. + ~> The private key is stored in Terraform state. Use a state backend with encryption at rest. + ~> Dokploy's update endpoint only accepts name and description. Changing either key forces a new resource. +--- + +# dokploy_ssh_key (Resource) + +An SSH key pair Dokploy uses to clone private Git repositories and to reach remote servers. + +~> The private key is stored in Terraform state. Use a state backend with encryption at rest. + +~> Dokploy's update endpoint only accepts `name` and `description`. Changing either key forces a new resource. + + + + +## Schema + +### Required + +- `name` (String) Display name of the key pair. +- `private_key` (String, Sensitive) PEM-encoded private key. +- `public_key` (String) OpenSSH-formatted public key. + +### Optional + +- `description` (String) Free-form description. + +### Read-Only + +- `created_at` (String) RFC 3339 timestamp of when the key was created. +- `id` (String) Unique SSH key identifier. +- `last_used_at` (String) RFC 3339 timestamp of when the key was last used, if ever. diff --git a/examples/complete/main.tf b/examples/complete/main.tf new file mode 100644 index 0000000..8edd244 --- /dev/null +++ b/examples/complete/main.tf @@ -0,0 +1,200 @@ +# A complete Dokploy environment: a project, a staging environment, a public +# web service with a database and a cache behind it, and a Compose stack. +# +# export DOKPLOY_HOST=https://dokploy.example.com +# export DOKPLOY_API_KEY=... +# terraform init && terraform apply + +terraform { + required_version = ">= 1.5" + required_providers { + dokploy = { + source = "maxvojtkov/dokploy" + version = ">= 0.1" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} + +provider "dokploy" { + # host and api_key are read from DOKPLOY_HOST and DOKPLOY_API_KEY. +} + +# ------------------------------------------------------------------- structure + +resource "dokploy_project" "shop" { + name = "shop" + description = "Storefront and its backing services" + + # Shared by every service in the project. + env = "TZ=Europe/Bucharest" +} + +# Dokploy creates a default "production" environment with the project; add a +# second one for staging. +resource "dokploy_environment" "staging" { + name = "staging" + description = "Pre-production" + project_id = dokploy_project.shop.id + + env = "LOG_LEVEL=debug" +} + +# -------------------------------------------------------------------- secrets + +resource "random_password" "postgres" { + length = 32 + special = false +} + +resource "random_password" "redis" { + length = 32 + special = false +} + +# ------------------------------------------------------------------- datastores + +resource "dokploy_postgres" "shop" { + name = "shop-db" + environment_id = dokploy_project.shop.default_environment_id + + docker_image = "postgres:16-alpine" + database_name = "shop" + database_user = "shop" + database_password = random_password.postgres.result + + memory_limit = "1g" + cpu_limit = "1" + memory_reservation = "256m" +} + +resource "dokploy_redis" "sessions" { + name = "shop-cache" + environment_id = dokploy_project.shop.default_environment_id + + docker_image = "redis:7-alpine" + database_password = random_password.redis.result + + memory_limit = "256m" +} + +# --------------------------------------------------------------- the web service + +module "storefront" { + source = "../../modules/web-service" + + name = "storefront" + description = "Public storefront" + environment_id = dokploy_project.shop.default_environment_id + + service_source = { + type = "github" + github_id = var.github_provider_id + owner = "acme" + repository = "storefront" + branch = "main" + } + + build_type = "nixpacks" + auto_deploy = true + replicas = 2 + + # Dokploy resolves service names on the shared Docker network, so the + # database is reachable by its generated app_name. + env = join("\n", [ + "DATABASE_URL=postgresql://shop:${random_password.postgres.result}@${dokploy_postgres.shop.app_name}:5432/shop", + "REDIS_URL=redis://:${random_password.redis.result}@${dokploy_redis.sessions.app_name}:6379", + "NODE_ENV=production", + ]) + + resources = { + memory_limit = "1g" + cpu_limit = "1" + } + + domains = [ + { + host = "shop.example.com" + port = 3000 + }, + ] + + redirects = [ + { + regex = "^https://www\\.shop\\.example\\.com/(.*)" + replacement = "https://shop.example.com/$${1}" + permanent = true + }, + ] + + mounts = [ + { + mount_path = "/app/uploads" + type = "volume" + volume_name = "storefront-uploads" + }, + ] +} + +# -------------------------------------------- an internal tool behind basic auth + +module "admin" { + source = "../../modules/web-service" + + name = "admin" + environment_id = dokploy_environment.staging.id + + service_source = { + type = "docker" + image = "traefik/whoami:latest" + } + + domains = [ + { + host = "admin.staging.example.com" + port = 80 + }, + ] + + basic_auth = { + username = "ops" + password = var.admin_password + } +} + +# --------------------------------------------------------------- a compose stack + +resource "dokploy_compose" "observability" { + name = "observability" + environment_id = dokploy_project.shop.default_environment_id + compose_type = "docker-compose" + source_type = "raw" + + # Preserve volumes if this stack is ever destroyed. + delete_volumes = false + + compose_file = <<-YAML + services: + uptime: + image: louislam/uptime-kuma:1 + volumes: + - uptime-data:/app/data + restart: unless-stopped + + volumes: + uptime-data: + YAML +} + +resource "dokploy_domain" "observability" { + compose_id = dokploy_compose.observability.id + domain_type = "compose" + service_name = "uptime" + host = "status.example.com" + port = 3001 + https = true + certificate_type = "letsencrypt" +} diff --git a/examples/complete/outputs.tf b/examples/complete/outputs.tf new file mode 100644 index 0000000..487aee6 --- /dev/null +++ b/examples/complete/outputs.tf @@ -0,0 +1,19 @@ +output "project_id" { + description = "Identifier of the shop project." + value = dokploy_project.shop.id +} + +output "production_environment_id" { + description = "Default production environment created alongside the project." + value = dokploy_project.shop.default_environment_id +} + +output "storefront_urls" { + description = "Public URLs the storefront answers on." + value = module.storefront.urls +} + +output "postgres_host" { + description = "In-cluster hostname of the database, for other services to connect to." + value = dokploy_postgres.shop.app_name +} diff --git a/examples/complete/variables.tf b/examples/complete/variables.tf new file mode 100644 index 0000000..678dc75 --- /dev/null +++ b/examples/complete/variables.tf @@ -0,0 +1,14 @@ +variable "github_provider_id" { + description = <<-DESC + ID of the GitHub connection configured in Dokploy under + Settings -> Git Providers. Find it with: + curl -H "x-api-key: $DOKPLOY_API_KEY" "$DOKPLOY_HOST/api/github.githubProviders" + DESC + type = string +} + +variable "admin_password" { + description = "Basic auth password for the staging admin tool." + type = string + sensitive = true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fe1ea3d --- /dev/null +++ b/go.mod @@ -0,0 +1,64 @@ +module github.com/maxvojtkov/terraform-provider-dokploy + +go 1.25.8 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 + github.com/hashicorp/terraform-plugin-go v0.31.0 + github.com/hashicorp/terraform-plugin-log v0.10.0 + github.com/hashicorp/terraform-plugin-testing v1.16.0 +) + +require ( + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/hc-install v0.9.4 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.25.1 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.2.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.18.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7c6d165 --- /dev/null +++ b/go.sum @@ -0,0 +1,246 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc= +github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.25.1 h1:PRutYRGM8pixV3B8812NYoBK5O+yuf3qcB/70KFKGiU= +github.com/hashicorp/terraform-exec v0.25.1/go.mod h1:+izOYrs9sKMQK4OYvGDnrSSJHY/pm4e4eXFqSL2Q5mA= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= +github.com/hashicorp/terraform-plugin-framework-validators v0.18.0 h1:OQnlOt98ua//rCw+QhBbSqfW3QbwtVrcdWeQN5gI3Hw= +github.com/hashicorp/terraform-plugin-framework-validators v0.18.0/go.mod h1:lZvZvagw5hsJwuY7mAY6KUz45/U6fiDR0CzQAwWD0CA= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-plugin-testing v1.16.0 h1:GB97nGnJ1hESpDrCjqZig38RodSF0gdRzxlDupLXP38= +github.com/hashicorp/terraform-plugin-testing v1.16.0/go.mod h1:eQPYAy9xFMV7xtIFX8Y+wJGtUB++HBl329zCF6PBMZk= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= +github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= +github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..5712fb6 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,280 @@ +// Package client implements a thin HTTP client for the Dokploy API. +// +// Dokploy exposes its entire tRPC router over REST via @dokploy/trpc-openapi. +// Every procedure is reachable at `/api/.`: +// +// queries -> GET with flat query parameters +// mutations -> POST with a flat JSON body +// +// Authentication is a static API token sent in the `x-api-key` header. +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const defaultTimeout = 60 * time.Second + +// Client talks to a single Dokploy instance. +type Client struct { + baseURL string + token string + http *http.Client + + // Several create endpoints validate an organizationId that the server then + // overrides with the token's own organization. It is fetched once and + // reused. + orgOnce sync.Once + orgID string + orgErr error +} + +// OrganizationID returns the organization the API token belongs to. +// +// Dokploy's Zod schemas for `sshKey.create`, `certificates.create` and friends +// require an organizationId even though the router replaces it with the +// session's own organization, so the provider has to supply something valid. +func (c *Client) OrganizationID(ctx context.Context) (string, error) { + c.orgOnce.Do(func() { + raw, err := c.Query(ctx, "user.get", nil) + if err != nil { + c.orgErr = fmt.Errorf("resolving the token's organization via user.get: %w", err) + return + } + var payload struct { + OrganizationID string `json:"organizationId"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + c.orgErr = fmt.Errorf("decoding user.get response: %w", err) + return + } + if payload.OrganizationID == "" { + c.orgErr = fmt.Errorf("user.get did not report an organizationId") + return + } + c.orgID = payload.OrganizationID + }) + return c.orgID, c.orgErr +} + +// New builds a client for the given host (e.g. "https://dokploy.example.com"). +// A trailing slash and/or a trailing "/api" are both tolerated. +func New(host, token string, timeout time.Duration, insecureClient *http.Client) (*Client, error) { + trimmed := strings.TrimRight(strings.TrimSpace(host), "/") + if trimmed == "" { + return nil, fmt.Errorf("host must not be empty") + } + trimmed = strings.TrimSuffix(trimmed, "/api") + + u, err := url.Parse(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid host %q: %w", host, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("host must start with http:// or https://, got %q", host) + } + + httpClient := insecureClient + if httpClient == nil { + httpClient = &http.Client{} + } + if timeout <= 0 { + timeout = defaultTimeout + } + httpClient.Timeout = timeout + + return &Client{baseURL: trimmed + "/api", token: token, http: httpClient}, nil +} + +// APIError is a structured Dokploy error response. +type APIError struct { + StatusCode int + Message string `json:"message"` + Code string `json:"code"` + Path string + // FieldErrors holds Zod per-field validation messages, when present. + FieldErrors map[string][]string + raw string +} + +func (e *APIError) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "dokploy API error (HTTP %d)", e.StatusCode) + if e.Code != "" { + fmt.Fprintf(&b, " %s", e.Code) + } + if e.Path != "" { + fmt.Fprintf(&b, " on %s", e.Path) + } + if e.Message != "" { + fmt.Fprintf(&b, ": %s", e.Message) + } + for field, msgs := range e.FieldErrors { + fmt.Fprintf(&b, "\n - %s: %s", field, strings.Join(msgs, "; ")) + } + if e.Message == "" && len(e.FieldErrors) == 0 && e.raw != "" { + fmt.Fprintf(&b, ": %s", truncate(e.raw, 500)) + } + return b.String() +} + +// IsNotFound reports whether err represents a missing resource. Terraform uses +// this to drop a resource from state instead of failing the run. +func IsNotFound(err error) bool { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.StatusCode == http.StatusNotFound || apiErr.Code == "NOT_FOUND" { + return true + } + // Dokploy frequently surfaces a missing row as a 500 with a message from + // the service layer rather than a typed NOT_FOUND. + msg := strings.ToLower(apiErr.Message) + return strings.Contains(msg, "not found") || strings.Contains(msg, "doesn't exist") +} + +// Query performs a GET against a tRPC query procedure. Non-nil values in input +// are flattened into query parameters. +func (c *Client) Query(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) { + endpoint := c.baseURL + "/" + procedure + if len(input) > 0 { + values := url.Values{} + for k, v := range input { + if v == nil { + continue + } + values.Set(k, stringify(v)) + } + if encoded := values.Encode(); encoded != "" { + endpoint += "?" + encoded + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + return c.do(req, procedure) +} + +// Mutate performs a POST against a tRPC mutation procedure with a JSON body. +func (c *Client) Mutate(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) { + if input == nil { + input = map[string]any{} + } + body, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("encoding request body for %s: %w", procedure, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+procedure, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req, procedure) +} + +func (c *Client) do(req *http.Request, procedure string) (json.RawMessage, error) { + req.Header.Set("x-api-key", c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("calling %s: %w", procedure, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from %s: %w", procedure, err) + } + + if resp.StatusCode >= 400 { + return nil, parseAPIError(resp.StatusCode, procedure, raw) + } + return json.RawMessage(raw), nil +} + +func parseAPIError(status int, procedure string, raw []byte) *APIError { + apiErr := &APIError{StatusCode: status, Path: procedure, raw: string(raw)} + + var envelope struct { + Message string `json:"message"` + Code string `json:"code"` + Data struct { + Code string `json:"code"` + Path string `json:"path"` + ZodError *struct { + FieldErrors map[string][]string `json:"fieldErrors"` + FormErrors []string `json:"formErrors"` + } `json:"zodError"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return apiErr + } + + apiErr.Message = envelope.Message + apiErr.Code = firstNonEmpty(envelope.Code, envelope.Data.Code) + if envelope.Data.Path != "" { + apiErr.Path = envelope.Data.Path + } + if z := envelope.Data.ZodError; z != nil { + if len(z.FieldErrors) > 0 { + apiErr.FieldErrors = z.FieldErrors + } + if len(z.FormErrors) > 0 { + if apiErr.FieldErrors == nil { + apiErr.FieldErrors = map[string][]string{} + } + apiErr.FieldErrors["(form)"] = z.FormErrors + } + } + return apiErr +} + +func stringify(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + return strconv.FormatBool(t) + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + default: + return fmt.Sprintf("%v", t) + } +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/provider/data_sources.go b/internal/provider/data_sources.go new file mode 100644 index 0000000..a23e84b --- /dev/null +++ b/internal/provider/data_sources.go @@ -0,0 +1,467 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" +) + +// baseDataSource wires the shared API client into each data source. +type baseDataSource struct { + api *client.Client +} + +func (d *baseDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + api, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected provider data", + fmt.Sprintf("Expected *client.Client, got %T. This is a bug in the provider.", req.ProviderData), + ) + return + } + d.api = api +} + +// ------------------------------------------------------------ dokploy_project + +type projectDataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Env types.String `tfsdk:"env"` + CreatedAt types.String `tfsdk:"created_at"` + OrganizationID types.String `tfsdk:"organization_id"` + DefaultEnvironmentID types.String `tfsdk:"default_environment_id"` + Environments types.List `tfsdk:"environments"` +} + +type projectDataSource struct{ baseDataSource } + +func newProjectDataSource() datasource.DataSource { return &projectDataSource{} } + +func (d *projectDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project" +} + +var environmentObjectType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "is_default": types.BoolType, +}} + +func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up an existing Dokploy project by ID, including its environments.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Required: true, MarkdownDescription: "Project identifier."}, + "name": schema.StringAttribute{Computed: true, MarkdownDescription: "Project name."}, + "description": schema.StringAttribute{Computed: true, MarkdownDescription: "Project description."}, + "env": schema.StringAttribute{Computed: true, MarkdownDescription: "Project-wide environment variables."}, + "created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."}, + "organization_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning organization."}, + "default_environment_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ID of the project's default environment.", + }, + "environments": schema.ListAttribute{ + Computed: true, + ElementType: environmentObjectType, + MarkdownDescription: "Every environment in the project.", + }, + }, + } +} + +func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config projectDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + raw, err := d.api.Query(ctx, "project.one", map[string]any{"projectId": config.ID.ValueString()}) + if err != nil { + resp.Diagnostics.AddError("Failed to read project", err.Error()) + return + } + + var payload struct { + ProjectID string `json:"projectId"` + Name string `json:"name"` + Description *string `json:"description"` + Env string `json:"env"` + CreatedAt string `json:"createdAt"` + OrganizationID string `json:"organizationId"` + Environments []struct { + EnvironmentID string `json:"environmentId"` + Name string `json:"name"` + IsDefault bool `json:"isDefault"` + } `json:"environments"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + resp.Diagnostics.AddError("Failed to decode project", err.Error()) + return + } + + config.Name = types.StringValue(payload.Name) + config.Description = optionalStringValue(payload.Description) + config.Env = types.StringValue(payload.Env) + config.CreatedAt = types.StringValue(payload.CreatedAt) + config.OrganizationID = types.StringValue(payload.OrganizationID) + config.DefaultEnvironmentID = types.StringNull() + + elements := make([]attr.Value, 0, len(payload.Environments)) + for _, env := range payload.Environments { + if env.IsDefault { + config.DefaultEnvironmentID = types.StringValue(env.EnvironmentID) + } + object, diags := types.ObjectValue(environmentObjectType.AttrTypes, map[string]attr.Value{ + "id": types.StringValue(env.EnvironmentID), + "name": types.StringValue(env.Name), + "is_default": types.BoolValue(env.IsDefault), + }) + resp.Diagnostics.Append(diags...) + elements = append(elements, object) + } + if resp.Diagnostics.HasError() { + return + } + + list, diags := types.ListValue(environmentObjectType, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + config.Environments = list + + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +// ----------------------------------------------------------- dokploy_projects + +type projectsDataSourceModel struct { + Projects types.List `tfsdk:"projects"` +} + +type projectsDataSource struct{ baseDataSource } + +func newProjectsDataSource() datasource.DataSource { return &projectsDataSource{} } + +func (d *projectsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_projects" +} + +var projectSummaryType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "description": types.StringType, + "default_environment_id": types.StringType, +}} + +func (d *projectsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "List every Dokploy project the API token can see.", + Attributes: map[string]schema.Attribute{ + "projects": schema.ListAttribute{ + Computed: true, + ElementType: projectSummaryType, + MarkdownDescription: "All visible projects.", + }, + }, + } +} + +func (d *projectsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + raw, err := d.api.Query(ctx, "project.all", nil) + if err != nil { + resp.Diagnostics.AddError("Failed to list projects", err.Error()) + return + } + + var payload []struct { + ProjectID string `json:"projectId"` + Name string `json:"name"` + Description *string `json:"description"` + Environments []struct { + EnvironmentID string `json:"environmentId"` + IsDefault bool `json:"isDefault"` + } `json:"environments"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + resp.Diagnostics.AddError("Failed to decode projects", err.Error()) + return + } + + elements := make([]attr.Value, 0, len(payload)) + for _, project := range payload { + defaultEnv := types.StringNull() + for _, env := range project.Environments { + if env.IsDefault { + defaultEnv = types.StringValue(env.EnvironmentID) + break + } + } + object, diags := types.ObjectValue(projectSummaryType.AttrTypes, map[string]attr.Value{ + "id": types.StringValue(project.ProjectID), + "name": types.StringValue(project.Name), + "description": optionalStringValue(project.Description), + "default_environment_id": defaultEnv, + }) + resp.Diagnostics.Append(diags...) + elements = append(elements, object) + } + if resp.Diagnostics.HasError() { + return + } + + list, diags := types.ListValue(projectSummaryType, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &projectsDataSourceModel{Projects: list})...) +} + +// -------------------------------------------------------- dokploy_environment + +type environmentDataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + ProjectID types.String `tfsdk:"project_id"` + Env types.String `tfsdk:"env"` + IsDefault types.Bool `tfsdk:"is_default"` + CreatedAt types.String `tfsdk:"created_at"` +} + +type environmentDataSource struct{ baseDataSource } + +func newEnvironmentDataSource() datasource.DataSource { return &environmentDataSource{} } + +func (d *environmentDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_environment" +} + +func (d *environmentDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up an existing environment by ID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Required: true, MarkdownDescription: "Environment identifier."}, + "name": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment name."}, + "description": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment description."}, + "project_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning project."}, + "env": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment-wide variables."}, + "is_default": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether this is the project's default environment."}, + "created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."}, + }, + } +} + +func (d *environmentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config environmentDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + raw, err := d.api.Query(ctx, "environment.one", map[string]any{"environmentId": config.ID.ValueString()}) + if err != nil { + resp.Diagnostics.AddError("Failed to read environment", err.Error()) + return + } + + var payload struct { + Name string `json:"name"` + Description *string `json:"description"` + ProjectID string `json:"projectId"` + Env string `json:"env"` + IsDefault bool `json:"isDefault"` + CreatedAt string `json:"createdAt"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + resp.Diagnostics.AddError("Failed to decode environment", err.Error()) + return + } + + config.Name = types.StringValue(payload.Name) + config.Description = optionalStringValue(payload.Description) + config.ProjectID = types.StringValue(payload.ProjectID) + config.Env = types.StringValue(payload.Env) + config.IsDefault = types.BoolValue(payload.IsDefault) + config.CreatedAt = types.StringValue(payload.CreatedAt) + + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +// -------------------------------------------------------- dokploy_application + +type applicationDataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + AppName types.String `tfsdk:"app_name"` + Description types.String `tfsdk:"description"` + EnvironmentID types.String `tfsdk:"environment_id"` + ApplicationStatus types.String `tfsdk:"application_status"` + SourceType types.String `tfsdk:"source_type"` + BuildType types.String `tfsdk:"build_type"` + CreatedAt types.String `tfsdk:"created_at"` +} + +type applicationDataSource struct{ baseDataSource } + +func newApplicationDataSource() datasource.DataSource { return &applicationDataSource{} } + +func (d *applicationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_application" +} + +func (d *applicationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Look up an existing application by ID.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Required: true, MarkdownDescription: "Application identifier."}, + "name": schema.StringAttribute{Computed: true, MarkdownDescription: "Application name."}, + "app_name": schema.StringAttribute{Computed: true, MarkdownDescription: "Docker service name."}, + "description": schema.StringAttribute{Computed: true, MarkdownDescription: "Application description."}, + "environment_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning environment."}, + "application_status": schema.StringAttribute{Computed: true, MarkdownDescription: "Current status."}, + "source_type": schema.StringAttribute{Computed: true, MarkdownDescription: "Source of the code or image."}, + "build_type": schema.StringAttribute{Computed: true, MarkdownDescription: "Build strategy."}, + "created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."}, + }, + } +} + +func (d *applicationDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config applicationDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + raw, err := d.api.Query(ctx, "application.one", map[string]any{"applicationId": config.ID.ValueString()}) + if err != nil { + resp.Diagnostics.AddError("Failed to read application", err.Error()) + return + } + + var payload struct { + Name string `json:"name"` + AppName string `json:"appName"` + Description *string `json:"description"` + EnvironmentID string `json:"environmentId"` + ApplicationStatus string `json:"applicationStatus"` + SourceType string `json:"sourceType"` + BuildType string `json:"buildType"` + CreatedAt string `json:"createdAt"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + resp.Diagnostics.AddError("Failed to decode application", err.Error()) + return + } + + config.Name = types.StringValue(payload.Name) + config.AppName = types.StringValue(payload.AppName) + config.Description = optionalStringValue(payload.Description) + config.EnvironmentID = types.StringValue(payload.EnvironmentID) + config.ApplicationStatus = types.StringValue(payload.ApplicationStatus) + config.SourceType = types.StringValue(payload.SourceType) + config.BuildType = types.StringValue(payload.BuildType) + config.CreatedAt = types.StringValue(payload.CreatedAt) + + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +// ------------------------------------------------------------ dokploy_servers + +type serversDataSourceModel struct { + Servers types.List `tfsdk:"servers"` +} + +type serversDataSource struct{ baseDataSource } + +func newServersDataSource() datasource.DataSource { return &serversDataSource{} } + +func (d *serversDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_servers" +} + +var serverSummaryType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "ip_address": types.StringType, + "server_status": types.StringType, +}} + +func (d *serversDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "List the remote servers registered with Dokploy. Use a server's `id` as the " + + "`server_id` of an application, stack, or database to deploy it away from the Dokploy host.", + Attributes: map[string]schema.Attribute{ + "servers": schema.ListAttribute{ + Computed: true, + ElementType: serverSummaryType, + MarkdownDescription: "All registered servers.", + }, + }, + } +} + +func (d *serversDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + raw, err := d.api.Query(ctx, "server.all", nil) + if err != nil { + resp.Diagnostics.AddError("Failed to list servers", err.Error()) + return + } + + var payload []struct { + ServerID string `json:"serverId"` + Name string `json:"name"` + IPAddress *string `json:"ipAddress"` + ServerStatus *string `json:"serverStatus"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + resp.Diagnostics.AddError("Failed to decode servers", err.Error()) + return + } + + elements := make([]attr.Value, 0, len(payload)) + for _, server := range payload { + object, diags := types.ObjectValue(serverSummaryType.AttrTypes, map[string]attr.Value{ + "id": types.StringValue(server.ServerID), + "name": types.StringValue(server.Name), + "ip_address": optionalStringValue(server.IPAddress), + "server_status": optionalStringValue(server.ServerStatus), + }) + resp.Diagnostics.Append(diags...) + elements = append(elements, object) + } + if resp.Diagnostics.HasError() { + return + } + + list, diags := types.ListValue(serverSummaryType, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &serversDataSourceModel{Servers: list})...) +} + +func optionalStringValue(value *string) types.String { + if value == nil { + return types.StringNull() + } + return types.StringValue(*value) +} diff --git a/internal/provider/generic.go b/internal/provider/generic.go new file mode 100644 index 0000000..7a821d3 --- /dev/null +++ b/internal/provider/generic.go @@ -0,0 +1,508 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" + "github.com/maxvojtkov/terraform-provider-dokploy/internal/tfmap" +) + +// ResourceSpec describes a Dokploy resource in terms of the tRPC procedures +// that back it. The generic resource implementation below turns one of these +// into a full Terraform resource. +type ResourceSpec struct { + // Name is the type name without the provider prefix, e.g. "project" + // becomes `dokploy_project`. + Name string + + // Schema is the Terraform schema presented to practitioners. + Schema schema.Schema + + // NewModel returns a pointer to a zero-valued model struct. + NewModel func() any + + CreateProc string + ReadProc string + UpdateProc string + DeleteProc string + + // CreateResponseKey extracts a nested object from the create response. + // `project.create`, for example, returns {"project":{...},"environment":{...}}. + CreateResponseKey string + + // UpdateAfterCreate issues an update immediately after create. Dokploy's + // `application.create` and `compose.create` accept only a small subset of + // fields; everything else has to be written through `*.update`. + UpdateAfterCreate bool + + // DeleteExtra contributes fixed fields to the delete body. + DeleteExtra map[string]any + + // DeleteBody contributes fields derived from state, for delete procedures + // that take options — such as compose's required `deleteVolumes`. + DeleteBody func(model any) map[string]any + + // ImportIDAttribute names the attribute that `terraform import` populates. + // Defaults to "id". + ImportIDAttribute string + + // NeedsOrganizationID adds the token's organization to the create body. + // Dokploy validates `organizationId` on a few create endpoints and then + // overwrites it with the session's own organization, so it has to be + // present but its value does not matter. + NeedsOrganizationID bool + + // ListIDs snapshots the IDs that currently exist, for resources whose + // create procedure returns no identifier (Dokploy's `sshKey.create`, + // `redirects.create` and `security.create` return `true` or nothing). + // The generic create diffs the snapshot taken before and after the call to + // discover the new ID. + ListIDs func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) + + // PostRead derives extra model fields from the raw read response, for + // values that are not plain top-level columns (for example a project's + // default environment, which arrives nested under `environments`). + PostRead func(raw json.RawMessage, model any) error +} + +// genericResource adapts a ResourceSpec to the plugin framework interfaces. +type genericResource struct { + spec ResourceSpec + api *client.Client +} + +var ( + _ resource.Resource = &genericResource{} + _ resource.ResourceWithConfigure = &genericResource{} + _ resource.ResourceWithImportState = &genericResource{} +) + +func newGenericResource(spec ResourceSpec) func() resource.Resource { + return func() resource.Resource { return &genericResource{spec: spec} } +} + +func (r *genericResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_" + r.spec.Name +} + +func (r *genericResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = r.spec.Schema +} + +func (r *genericResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + api, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected provider data", + fmt.Sprintf("Expected *client.Client, got %T. This is a bug in the provider.", req.ProviderData), + ) + return + } + r.api = api +} + +func (r *genericResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + model := r.spec.NewModel() + resp.Diagnostics.Append(req.Plan.Get(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + body, err := tfmap.ToAPI(model, tfmap.PhaseCreate) + if err != nil { + resp.Diagnostics.AddError("Failed to build create request", err.Error()) + return + } + + if r.spec.NeedsOrganizationID { + orgID, err := r.api.OrganizationID(ctx) + if err != nil { + resp.Diagnostics.AddError("Failed to resolve the Dokploy organization", err.Error()) + return + } + body["organizationId"] = orgID + } + + // Snapshot existing IDs when the create procedure will not return one. + var before map[string]struct{} + if r.spec.ListIDs != nil { + before, err = r.spec.ListIDs(ctx, r.api, model) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to enumerate existing %s resources", r.spec.Name), + err.Error(), + ) + return + } + } + + tflog.Debug(ctx, "dokploy create", map[string]any{"procedure": r.spec.CreateProc}) + raw, err := r.api.Mutate(ctx, r.spec.CreateProc, body) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create %s", r.spec.Name), err.Error()) + return + } + + var id string + if r.spec.ListIDs != nil { + id, err = discoverNewID(ctx, r.api, r.spec, model, before) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to identify the new %s", r.spec.Name), err.Error()) + return + } + if err := tfmap.SetID(model, id); err != nil { + resp.Diagnostics.AddError("Failed to set ID", err.Error()) + return + } + } else { + created, err := unwrap(raw, r.spec.CreateResponseKey) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Unexpected create response for %s", r.spec.Name), err.Error()) + return + } + if err := tfmap.FromAPI(created, model); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to decode created %s", r.spec.Name), err.Error()) + return + } + if id, err = tfmap.IDValue(model); err != nil || id == "" { + resp.Diagnostics.AddError( + fmt.Sprintf("Create response for %s did not contain an ID", r.spec.Name), + fmt.Sprintf("response: %s", truncate(string(created), 500)), + ) + return + } + } + + // Dokploy's create procedures accept only a subset of fields for some + // resources. Write the remainder through update before reading back. + if r.spec.UpdateAfterCreate && r.spec.UpdateProc != "" { + // Re-read the plan so update-only fields are taken from configuration + // rather than from the create response. + planModel := r.spec.NewModel() + resp.Diagnostics.Append(req.Plan.Get(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } + if err := tfmap.SetID(planModel, id); err != nil { + resp.Diagnostics.AddError("Failed to set ID on plan model", err.Error()) + return + } + updateBody, err := tfmap.ToAPI(planModel, tfmap.PhaseUpdate) + if err != nil { + resp.Diagnostics.AddError("Failed to build post-create update request", err.Error()) + return + } + if len(updateBody) > 1 { // more than the ID alone + if _, err := r.api.Mutate(ctx, r.spec.UpdateProc, updateBody); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to apply configuration to new %s", r.spec.Name), + fmt.Sprintf("The %s was created (id %s) but configuring it failed: %s", r.spec.Name, id, err.Error()), + ) + return + } + } + } + + if !r.refresh(ctx, model, id, &resp.Diagnostics, true) { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) +} + +func (r *genericResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + model := r.spec.NewModel() + resp.Diagnostics.Append(req.State.Get(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + id, err := tfmap.IDValue(model) + if err != nil { + resp.Diagnostics.AddError("Failed to read ID from state", err.Error()) + return + } + + raw, err := r.read(ctx, id) + if err != nil { + if client.IsNotFound(err) { + tflog.Info(ctx, "dokploy resource is gone, removing from state", map[string]any{ + "resource": r.spec.Name, "id": id, + }) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("Failed to read %s", r.spec.Name), err.Error()) + return + } + + if err := tfmap.FromAPI(raw, model); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to decode %s", r.spec.Name), err.Error()) + return + } + if r.spec.PostRead != nil { + if err := r.spec.PostRead(raw, model); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to derive attributes for %s", r.spec.Name), err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) +} + +func (r *genericResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + model := r.spec.NewModel() + resp.Diagnostics.Append(req.Plan.Get(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + // The plan may not carry the ID if it is computed; fall back to state. + id, err := tfmap.IDValue(model) + if err != nil { + resp.Diagnostics.AddError("Failed to read ID from plan", err.Error()) + return + } + if id == "" { + stateModel := r.spec.NewModel() + resp.Diagnostics.Append(req.State.Get(ctx, stateModel)...) + if resp.Diagnostics.HasError() { + return + } + if id, err = tfmap.IDValue(stateModel); err != nil { + resp.Diagnostics.AddError("Failed to read ID from state", err.Error()) + return + } + if err := tfmap.SetID(model, id); err != nil { + resp.Diagnostics.AddError("Failed to set ID on plan model", err.Error()) + return + } + } + + if r.spec.UpdateProc == "" { + resp.Diagnostics.AddError( + fmt.Sprintf("%s does not support in-place updates", r.spec.Name), + "This is a bug in the provider: the resource should mark all attributes as RequiresReplace.", + ) + return + } + + body, err := tfmap.ToAPI(model, tfmap.PhaseUpdate) + if err != nil { + resp.Diagnostics.AddError("Failed to build update request", err.Error()) + return + } + + tflog.Debug(ctx, "dokploy update", map[string]any{"procedure": r.spec.UpdateProc, "id": id}) + if _, err := r.api.Mutate(ctx, r.spec.UpdateProc, body); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to update %s", r.spec.Name), err.Error()) + return + } + + if !r.refresh(ctx, model, id, &resp.Diagnostics, true) { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) +} + +func (r *genericResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + model := r.spec.NewModel() + resp.Diagnostics.Append(req.State.Get(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + + id, err := tfmap.IDValue(model) + if err != nil { + resp.Diagnostics.AddError("Failed to read ID from state", err.Error()) + return + } + idField, err := tfmap.IDAPIName(model) + if err != nil { + resp.Diagnostics.AddError("Failed to resolve ID field", err.Error()) + return + } + + body := map[string]any{idField: id} + for k, v := range r.spec.DeleteExtra { + body[k] = v + } + if r.spec.DeleteBody != nil { + for k, v := range r.spec.DeleteBody(model) { + body[k] = v + } + } + + tflog.Debug(ctx, "dokploy delete", map[string]any{"procedure": r.spec.DeleteProc, "id": id}) + if _, err := r.api.Mutate(ctx, r.spec.DeleteProc, body); err != nil { + if client.IsNotFound(err) { + return // Already gone; deletion is idempotent. + } + resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete %s", r.spec.Name), err.Error()) + } +} + +func (r *genericResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + attribute := r.spec.ImportIDAttribute + if attribute == "" { + attribute = "id" + } + resource.ImportStatePassthroughID(ctx, path.Root(attribute), req, resp) +} + +// read fetches the current remote representation of a resource. +func (r *genericResource) read(ctx context.Context, id string) (json.RawMessage, error) { + model := r.spec.NewModel() + idField, err := tfmap.IDAPIName(model) + if err != nil { + return nil, err + } + return r.api.Query(ctx, r.spec.ReadProc, map[string]any{idField: id}) +} + +// refresh re-reads the resource and decodes it into model. It returns false if +// diagnostics were added. +func (r *genericResource) refresh(ctx context.Context, model any, id string, diags *diag.Diagnostics, failOnMissing bool) bool { + raw, err := r.read(ctx, id) + if err != nil { + if client.IsNotFound(err) && !failOnMissing { + return true + } + diags.AddError( + fmt.Sprintf("Failed to read back %s after write", r.spec.Name), + fmt.Sprintf("The %s was written (id %s) but could not be read back: %s", r.spec.Name, id, err.Error()), + ) + return false + } + if err := tfmap.FromAPI(raw, model); err != nil { + diags.AddError(fmt.Sprintf("Failed to decode %s", r.spec.Name), err.Error()) + return false + } + if r.spec.PostRead != nil { + if err := r.spec.PostRead(raw, model); err != nil { + diags.AddError(fmt.Sprintf("Failed to derive attributes for %s", r.spec.Name), err.Error()) + return false + } + } + // Dokploy omits some columns from its responses. Anything the read did not + // report stays unknown, which Terraform rejects after apply, so settle it + // to null. + if err := tfmap.NullifyUnknown(model); err != nil { + diags.AddError(fmt.Sprintf("Failed to finalize %s state", r.spec.Name), err.Error()) + return false + } + return true +} + +// discoverNewID diffs the ID snapshots taken around a create call. +func discoverNewID( + ctx context.Context, + api *client.Client, + spec ResourceSpec, + model any, + before map[string]struct{}, +) (string, error) { + after, err := spec.ListIDs(ctx, api, model) + if err != nil { + return "", err + } + + var found []string + for id := range after { + if _, existed := before[id]; !existed { + found = append(found, id) + } + } + + switch len(found) { + case 1: + return found[0], nil + case 0: + return "", fmt.Errorf( + "the %s was created but no new record appeared; it may have been removed concurrently", + spec.Name, + ) + default: + return "", fmt.Errorf( + "the %s was created but %d new records appeared, so the new one is ambiguous. "+ + "This happens when several are created outside Terraform at the same time; "+ + "import the resource manually to continue", + spec.Name, len(found), + ) + } +} + +// collectIDs pulls a field out of every element of a JSON array. +func collectIDs(raw json.RawMessage, idField string) (map[string]struct{}, error) { + var items []map[string]json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil, fmt.Errorf("decoding list response: %w", err) + } + out := make(map[string]struct{}, len(items)) + for _, item := range items { + payload, ok := item[idField] + if !ok { + continue + } + var id string + if err := json.Unmarshal(payload, &id); err != nil { + continue + } + out[id] = struct{}{} + } + return out, nil +} + +// collectNestedIDs pulls IDs out of an array nested under a key, such as the +// `redirects` array inside an `application.one` response. +func collectNestedIDs(raw json.RawMessage, arrayKey, idField string) (map[string]struct{}, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + nested, ok := envelope[arrayKey] + if !ok { + return map[string]struct{}{}, nil + } + return collectIDs(nested, idField) +} + +// unwrap pulls a nested object out of a create response when the API wraps it. +func unwrap(raw json.RawMessage, key string) (json.RawMessage, error) { + if key == "" { + return raw, nil + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("decoding response envelope: %w", err) + } + nested, ok := envelope[key] + if !ok { + return nil, fmt.Errorf("expected key %q in response, got keys %v", key, keysOf(envelope)) + } + return nested, nil +} + +func keysOf(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..c83fd57 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,171 @@ +package provider + +import ( + "context" + "crypto/tls" + "net/http" + "os" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" +) + +// New returns the provider factory used by main.go and by acceptance tests. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &dokployProvider{version: version} + } +} + +type dokployProvider struct { + version string +} + +type providerModel struct { + Host types.String `tfsdk:"host"` + APIKey types.String `tfsdk:"api_key"` + Timeout types.Int64 `tfsdk:"timeout_seconds"` + SkipVerify types.Bool `tfsdk:"insecure_skip_verify"` +} + +func (p *dokployProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "dokploy" + resp.Version = p.version +} + +func (p *dokployProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manage [Dokploy](https://dokploy.com) projects, environments, applications, " + + "compose stacks, databases and networking with Terraform.", + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Base URL of the Dokploy instance, for example `https://dokploy.example.com`. " + + "A trailing `/api` is optional. May also be set with the `DOKPLOY_HOST` environment variable.", + }, + "api_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + MarkdownDescription: "API token generated in Dokploy under *Settings -> Profile -> API/CLI*. " + + "May also be set with the `DOKPLOY_API_KEY` environment variable.", + }, + "timeout_seconds": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "Per-request timeout in seconds. Defaults to `60`. May also be set with the " + + "`DOKPLOY_TIMEOUT_SECONDS` environment variable.", + }, + "insecure_skip_verify": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Skip TLS certificate verification. Only use this for instances behind a " + + "self-signed certificate. Defaults to `false`.", + }, + }, + } +} + +func (p *dokployProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config providerModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + host := stringOrEnv(config.Host, "DOKPLOY_HOST") + apiKey := stringOrEnv(config.APIKey, "DOKPLOY_API_KEY") + + if host == "" { + resp.Diagnostics.AddAttributeError( + path.Root("host"), + "Missing Dokploy host", + "Set the `host` provider attribute or the DOKPLOY_HOST environment variable.", + ) + } + if apiKey == "" { + resp.Diagnostics.AddAttributeError( + path.Root("api_key"), + "Missing Dokploy API key", + "Set the `api_key` provider attribute or the DOKPLOY_API_KEY environment variable.", + ) + } + if resp.Diagnostics.HasError() { + return + } + + timeout := 60 * time.Second + if !config.Timeout.IsNull() && !config.Timeout.IsUnknown() { + timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second + } else if env := os.Getenv("DOKPLOY_TIMEOUT_SECONDS"); env != "" { + if seconds, err := strconv.Atoi(env); err == nil { + timeout = time.Duration(seconds) * time.Second + } + } + + var httpClient *http.Client + if config.SkipVerify.ValueBool() { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in + }, + } + } + + api, err := client.New(host, apiKey, timeout, httpClient) + if err != nil { + resp.Diagnostics.AddError("Invalid Dokploy provider configuration", err.Error()) + return + } + + resp.DataSourceData = api + resp.ResourceData = api +} + +func (p *dokployProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + newGenericResource(projectResource()), + newGenericResource(environmentResource()), + newGenericResource(applicationResource()), + newGenericResource(composeResource()), + + newGenericResource(postgresResource()), + newGenericResource(mysqlResource()), + newGenericResource(mariadbResource()), + newGenericResource(mongoResource()), + newGenericResource(redisResource()), + + newGenericResource(domainResource()), + newGenericResource(mountResource()), + newGenericResource(portResource()), + newGenericResource(redirectResource()), + newGenericResource(securityResource()), + + newGenericResource(registryResource()), + newGenericResource(sshKeyResource()), + newGenericResource(certificateResource()), + newGenericResource(destinationResource()), + } +} + +func (p *dokployProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + newProjectDataSource, + newProjectsDataSource, + newEnvironmentDataSource, + newApplicationDataSource, + newServersDataSource, + } +} + +func stringOrEnv(value types.String, envVar string) string { + if !value.IsNull() && !value.IsUnknown() && value.ValueString() != "" { + return value.ValueString() + } + return os.Getenv(envVar) +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..d3e35ba --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,293 @@ +package provider_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/provider" +) + +// Acceptance tests talk to a real Dokploy instance. They create resources with +// a `tfacc-` prefix and destroy them again, so point them at a scratch +// instance rather than production: +// +// TF_ACC=1 \ +// DOKPLOY_HOST=https://dokploy.example.com \ +// DOKPLOY_API_KEY=... \ +// go test ./internal/provider/ -v -timeout 30m + +var protoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "dokploy": providerserver.NewProtocol6WithError(provider.New("test")()), +} + +func testAccPreCheck(t *testing.T) { + t.Helper() + for _, key := range []string{"DOKPLOY_HOST", "DOKPLOY_API_KEY"} { + if os.Getenv(key) == "" { + t.Fatalf("%s must be set for acceptance tests", key) + } + } +} + +func TestAccProject(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-project" + description = "created by acceptance tests" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_project.test", "name", "tfacc-project"), + resource.TestCheckResourceAttr("dokploy_project.test", "description", "created by acceptance tests"), + resource.TestCheckResourceAttrSet("dokploy_project.test", "id"), + // Dokploy creates a default environment with every project. + resource.TestCheckResourceAttrSet("dokploy_project.test", "default_environment_id"), + resource.TestCheckResourceAttrSet("dokploy_project.test", "organization_id"), + ), + }, + { + ResourceName: "dokploy_project.test", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-project-renamed" + description = "updated" + env = "SHARED=1" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_project.test", "name", "tfacc-project-renamed"), + resource.TestCheckResourceAttr("dokploy_project.test", "description", "updated"), + resource.TestCheckResourceAttr("dokploy_project.test", "env", "SHARED=1"), + ), + }, + }, + }) +} + +func TestAccEnvironment(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-env-project" +} + +resource "dokploy_environment" "test" { + name = "tfacc-staging" + project_id = dokploy_project.test.id + env = "STAGE=1" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_environment.test", "name", "tfacc-staging"), + // env is only writable through a follow-up update; assert it stuck. + resource.TestCheckResourceAttr("dokploy_environment.test", "env", "STAGE=1"), + resource.TestCheckResourceAttr("dokploy_environment.test", "is_default", "false"), + ), + }, + }, + }) +} + +// Applications are created with a minimal payload and then configured through +// application.update, so this asserts that update-only fields survive create. +func TestAccApplicationDockerSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccApplicationConfig("512m", 1), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_application.test", "source_type", "docker"), + resource.TestCheckResourceAttr("dokploy_application.test", "docker_image", "traefik/whoami:latest"), + resource.TestCheckResourceAttr("dokploy_application.test", "memory_limit", "512m"), + resource.TestCheckResourceAttr("dokploy_application.test", "replicas", "1"), + resource.TestCheckResourceAttr("dokploy_application.test", "env", "GREETING=hello"), + resource.TestCheckResourceAttrSet("dokploy_application.test", "app_name"), + ), + }, + { + ResourceName: "dokploy_application.test", + ImportState: true, + ImportStateVerify: true, + }, + { + // An in-place update must not force replacement. + Config: testAccApplicationConfig("1g", 3), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_application.test", "memory_limit", "1g"), + resource.TestCheckResourceAttr("dokploy_application.test", "replicas", "3"), + ), + }, + }, + }) +} + +func testAccApplicationConfig(memoryLimit string, replicas int) string { + return fmt.Sprintf(` +resource "dokploy_project" "test" { + name = "tfacc-app-project" +} + +resource "dokploy_application" "test" { + name = "tfacc-app" + environment_id = dokploy_project.test.default_environment_id + + source_type = "docker" + docker_image = "traefik/whoami:latest" + + env = "GREETING=hello" + memory_limit = %q + replicas = %d +}`, memoryLimit, replicas) +} + +func TestAccComposeAndDomain(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-compose-project" +} + +resource "dokploy_compose" "test" { + name = "tfacc-stack" + environment_id = dokploy_project.test.default_environment_id + compose_type = "docker-compose" + source_type = "raw" + + compose_file = <<-YAML + services: + whoami: + image: traefik/whoami:latest + YAML +} + +resource "dokploy_domain" "test" { + compose_id = dokploy_compose.test.id + domain_type = "compose" + service_name = "whoami" + host = "tfacc.example.invalid" + port = 80 + https = false + certificate_type = "none" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_compose.test", "compose_type", "docker-compose"), + resource.TestCheckResourceAttrSet("dokploy_compose.test", "app_name"), + resource.TestCheckResourceAttr("dokploy_domain.test", "host", "tfacc.example.invalid"), + resource.TestCheckResourceAttr("dokploy_domain.test", "service_name", "whoami"), + ), + }, + }, + }) +} + +// Databases follow the same create-then-update path as applications. +func TestAccPostgres(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-pg-project" +} + +resource "dokploy_postgres" "test" { + name = "tfacc-pg" + environment_id = dokploy_project.test.default_environment_id + docker_image = "postgres:16-alpine" + database_name = "acc" + database_user = "acc" + database_password = "acc-password" + memory_limit = "512m" +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("dokploy_postgres.test", "database_name", "acc"), + // memory_limit is rejected by postgres.create and must be + // written by the follow-up update. + resource.TestCheckResourceAttr("dokploy_postgres.test", "memory_limit", "512m"), + ), + }, + }, + }) +} + +// redirects.create returns `true` rather than a row, so the provider finds the +// new ID by diffing the application's redirect list. +func TestAccRedirectIDDiscovery(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-redirect-project" +} + +resource "dokploy_application" "test" { + name = "tfacc-redirect-app" + environment_id = dokploy_project.test.default_environment_id + source_type = "docker" + docker_image = "traefik/whoami:latest" +} + +resource "dokploy_redirect" "test" { + application_id = dokploy_application.test.id + regex = "^https://old\\.(.*)" + replacement = "https://new.$${1}" + permanent = true +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("dokploy_redirect.test", "id"), + resource.TestCheckResourceAttr("dokploy_redirect.test", "permanent", "true"), + ), + }, + }, + }) +} + +func TestAccProjectsDataSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: protoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "dokploy_project" "test" { + name = "tfacc-ds-project" +} + +data "dokploy_project" "test" { + id = dokploy_project.test.id +}`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.dokploy_project.test", "name", "tfacc-ds-project"), + resource.TestCheckResourceAttrSet("data.dokploy_project.test", "default_environment_id"), + resource.TestCheckResourceAttr("data.dokploy_project.test", "environments.#", "1"), + ), + }, + }, + }) +} diff --git a/internal/provider/resource_account.go b/internal/provider/resource_account.go new file mode 100644 index 0000000..92d8405 --- /dev/null +++ b/internal/provider/resource_account.go @@ -0,0 +1,190 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" +) + +// ------------------------------------------------------------------ Registry + +type registryModel struct { + ID types.String `tfsdk:"id" dokploy:"registryId,id"` + RegistryName types.String `tfsdk:"registry_name" dokploy:"registryName"` + Username types.String `tfsdk:"username" dokploy:"username"` + Password types.String `tfsdk:"password" dokploy:"password"` + RegistryURL types.String `tfsdk:"registry_url" dokploy:"registryUrl"` + RegistryType types.String `tfsdk:"registry_type" dokploy:"registryType"` + ImagePrefix types.String `tfsdk:"image_prefix" dokploy:"imagePrefix,nullable"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func registryResource() ResourceSpec { + return ResourceSpec{ + Name: "registry", + CreateProc: "registry.create", + ReadProc: "registry.one", + UpdateProc: "registry.update", + DeleteProc: "registry.remove", + NewModel: func() any { return ®istryModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A container registry that Dokploy pushes built images to and pulls them from.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique registry identifier."), + "registry_name": requiredString("Display name of the registry."), + "username": requiredString("Username used to authenticate to the registry."), + "password": sensitiveString("Password or access token used to authenticate.", true), + "registry_url": requiredString("Registry hostname, for example `ghcr.io`."), + "registry_type": enumStringWithDefault("Registry kind. Dokploy currently accepts only `cloud`.", + []string{"cloud"}, "cloud"), + "image_prefix": optionalString("Prefix prepended to pushed image names, for example an " + + "organization or namespace."), + "server_id": optionalReplaceString("Server this registry is scoped to."), + "created_at": computedString("RFC 3339 timestamp of when the registry was created."), + }, + }, + } +} + +// ------------------------------------------------------------------- SSH key + +type sshKeyModel struct { + ID types.String `tfsdk:"id" dokploy:"sshKeyId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + PublicKey types.String `tfsdk:"public_key" dokploy:"publicKey,create"` + PrivateKey types.String `tfsdk:"private_key" dokploy:"privateKey,create"` + LastUsedAt types.String `tfsdk:"last_used_at" dokploy:"lastUsedAt,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func sshKeyResource() ResourceSpec { + return ResourceSpec{ + Name: "ssh_key", + NeedsOrganizationID: true, + CreateProc: "sshKey.create", + ReadProc: "sshKey.one", + UpdateProc: "sshKey.update", + DeleteProc: "sshKey.remove", + NewModel: func() any { return &sshKeyModel{} }, + // `sshKey.create` returns nothing, so the new ID is discovered by + // diffing the key list around the call. + ListIDs: func(ctx context.Context, api *client.Client, _ any) (map[string]struct{}, error) { + raw, err := api.Query(ctx, "sshKey.all", nil) + if err != nil { + return nil, err + } + return collectIDs(raw, "sshKeyId") + }, + Schema: schema.Schema{ + MarkdownDescription: "An SSH key pair Dokploy uses to clone private Git repositories and to reach " + + "remote servers.\n\n" + + "~> The private key is stored in Terraform state. Use a state backend with encryption at rest.\n\n" + + "~> Dokploy's update endpoint only accepts `name` and `description`. Changing either key forces " + + "a new resource.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique SSH key identifier."), + "name": requiredString("Display name of the key pair."), + "description": optionalString("Free-form description."), + "public_key": requiredReplaceString("OpenSSH-formatted public key."), + "private_key": schema.StringAttribute{ + Required: true, + Sensitive: true, + MarkdownDescription: "PEM-encoded private key.", + PlanModifiers: requiresReplaceString(), + }, + "last_used_at": computedString("RFC 3339 timestamp of when the key was last used, if ever."), + "created_at": computedString("RFC 3339 timestamp of when the key was created."), + }, + }, + } +} + +// --------------------------------------------------------------- Certificate + +type certificateModel struct { + ID types.String `tfsdk:"id" dokploy:"certificateId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + CertificateData types.String `tfsdk:"certificate_data" dokploy:"certificateData"` + PrivateKey types.String `tfsdk:"private_key" dokploy:"privateKey"` + AutoRenew types.Bool `tfsdk:"auto_renew" dokploy:"autoRenew,create"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + CertificatePath types.String `tfsdk:"certificate_path" dokploy:"certificatePath,ro"` +} + +func certificateResource() ResourceSpec { + return ResourceSpec{ + Name: "certificate", + NeedsOrganizationID: true, + CreateProc: "certificates.create", + ReadProc: "certificates.one", + UpdateProc: "certificates.update", + DeleteProc: "certificates.remove", + NewModel: func() any { return &certificateModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A TLS certificate uploaded to Dokploy, for domains that use " + + "`certificate_type = \"custom\"`.\n\n" + + "~> The private key is stored in Terraform state. Use a state backend with encryption at rest.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique certificate identifier."), + "name": requiredString("Display name of the certificate."), + "certificate_data": requiredString("PEM-encoded certificate chain."), + "private_key": sensitiveString("PEM-encoded private key.", true), + "auto_renew": optionalComputedBool("Whether Dokploy should renew this certificate automatically."), + "server_id": optionalReplaceString("Server this certificate is installed on."), + "certificate_path": computedString("Path where Dokploy writes the certificate on disk."), + }, + }, + } +} + +// --------------------------------------------------------------- Destination + +type destinationModel struct { + ID types.String `tfsdk:"id" dokploy:"destinationId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + // `provider` is a reserved root attribute name in Terraform, so the + // attribute is exposed as `provider_name`. + Provider types.String `tfsdk:"provider_name" dokploy:"provider,nullable"` + AccessKey types.String `tfsdk:"access_key" dokploy:"accessKey"` + SecretAccessKey types.String `tfsdk:"secret_access_key" dokploy:"secretAccessKey"` + Bucket types.String `tfsdk:"bucket" dokploy:"bucket"` + Region types.String `tfsdk:"region" dokploy:"region"` + Endpoint types.String `tfsdk:"endpoint" dokploy:"endpoint"` + AdditionalFlags types.List `tfsdk:"additional_flags" dokploy:"additionalFlags,nullable"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func destinationResource() ResourceSpec { + return ResourceSpec{ + Name: "destination", + CreateProc: "destination.create", + ReadProc: "destination.one", + UpdateProc: "destination.update", + DeleteProc: "destination.remove", + NewModel: func() any { return &destinationModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "An S3-compatible bucket that Dokploy writes database and volume backups to.\n\n" + + "~> The secret access key is stored in Terraform state. Use a state backend with encryption at rest.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique destination identifier."), + "name": requiredString("Display name of the destination."), + "provider_name": optionalString("Provider label, for example `s3` or `cloudflare`. " + + "Named `provider_name` because `provider` is reserved by Terraform."), + "access_key": requiredString("S3 access key ID."), + "secret_access_key": sensitiveString("S3 secret access key.", true), + "bucket": requiredString("Bucket name."), + "region": requiredString("Bucket region, for example `us-east-1`."), + "endpoint": requiredString("S3 endpoint URL."), + "additional_flags": optionalComputedStringList("Extra flags passed to the underlying `rclone` invocation."), + "server_id": optionalString("Server this destination is scoped to."), + "created_at": computedString("Timestamp of when the destination was created."), + }, + }, + } +} diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go new file mode 100644 index 0000000..6081c57 --- /dev/null +++ b/internal/provider/resource_application.go @@ -0,0 +1,257 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type applicationModel struct { + ID types.String `tfsdk:"id" dokploy:"applicationId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + // Source + SourceType types.String `tfsdk:"source_type" dokploy:"sourceType"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage,nullable"` + Username types.String `tfsdk:"username" dokploy:"username,nullable"` + Password types.String `tfsdk:"password" dokploy:"password,nullable"` + RegistryURL types.String `tfsdk:"registry_url" dokploy:"registryUrl,nullable"` + Repository types.String `tfsdk:"repository" dokploy:"repository,nullable"` + Owner types.String `tfsdk:"owner" dokploy:"owner,nullable"` + Branch types.String `tfsdk:"branch" dokploy:"branch,nullable"` + BuildPath types.String `tfsdk:"build_path" dokploy:"buildPath,nullable"` + GithubID types.String `tfsdk:"github_id" dokploy:"githubId,nullable"` + TriggerType types.String `tfsdk:"trigger_type" dokploy:"triggerType,nullable"` + CustomGitURL types.String `tfsdk:"custom_git_url" dokploy:"customGitUrl,nullable"` + CustomGitBranch types.String `tfsdk:"custom_git_branch" dokploy:"customGitBranch,nullable"` + CustomGitPath types.String `tfsdk:"custom_git_build_path" dokploy:"customGitBuildPath,nullable"` + CustomGitSSHKey types.String `tfsdk:"custom_git_ssh_key_id" dokploy:"customGitSSHKeyId,nullable"` + GitlabID types.String `tfsdk:"gitlab_id" dokploy:"gitlabId,nullable"` + GitlabProjectID types.Int64 `tfsdk:"gitlab_project_id" dokploy:"gitlabProjectId,nullable"` + GitlabRepository types.String `tfsdk:"gitlab_repository" dokploy:"gitlabRepository,nullable"` + GitlabOwner types.String `tfsdk:"gitlab_owner" dokploy:"gitlabOwner,nullable"` + GitlabBranch types.String `tfsdk:"gitlab_branch" dokploy:"gitlabBranch,nullable"` + GitlabBuildPath types.String `tfsdk:"gitlab_build_path" dokploy:"gitlabBuildPath,nullable"` + GitlabNamespace types.String `tfsdk:"gitlab_path_namespace" dokploy:"gitlabPathNamespace,nullable"` + GiteaID types.String `tfsdk:"gitea_id" dokploy:"giteaId,nullable"` + GiteaRepository types.String `tfsdk:"gitea_repository" dokploy:"giteaRepository,nullable"` + GiteaOwner types.String `tfsdk:"gitea_owner" dokploy:"giteaOwner,nullable"` + GiteaBranch types.String `tfsdk:"gitea_branch" dokploy:"giteaBranch,nullable"` + GiteaBuildPath types.String `tfsdk:"gitea_build_path" dokploy:"giteaBuildPath,nullable"` + BitbucketID types.String `tfsdk:"bitbucket_id" dokploy:"bitbucketId,nullable"` + BitbucketRepo types.String `tfsdk:"bitbucket_repository" dokploy:"bitbucketRepository,nullable"` + BitbucketSlug types.String `tfsdk:"bitbucket_repository_slug" dokploy:"bitbucketRepositorySlug,nullable"` + BitbucketOwner types.String `tfsdk:"bitbucket_owner" dokploy:"bitbucketOwner,nullable"` + BitbucketBranch types.String `tfsdk:"bitbucket_branch" dokploy:"bitbucketBranch,nullable"` + BitbucketPath types.String `tfsdk:"bitbucket_build_path" dokploy:"bitbucketBuildPath,nullable"` + EnableSubmodules types.Bool `tfsdk:"enable_submodules" dokploy:"enableSubmodules"` + WatchPaths types.List `tfsdk:"watch_paths" dokploy:"watchPaths,nullable"` + AutoDeploy types.Bool `tfsdk:"auto_deploy" dokploy:"autoDeploy,nullable"` + + // Build + BuildType types.String `tfsdk:"build_type" dokploy:"buildType"` + Dockerfile types.String `tfsdk:"dockerfile" dokploy:"dockerfile,nullable"` + DockerContextPath types.String `tfsdk:"docker_context_path" dokploy:"dockerContextPath,nullable"` + DockerBuildStage types.String `tfsdk:"docker_build_stage" dokploy:"dockerBuildStage,nullable"` + PublishDirectory types.String `tfsdk:"publish_directory" dokploy:"publishDirectory,nullable"` + IsStaticSpa types.Bool `tfsdk:"is_static_spa" dokploy:"isStaticSpa,nullable"` + HerokuVersion types.String `tfsdk:"heroku_version" dokploy:"herokuVersion,nullable"` + RailpackVersion types.String `tfsdk:"railpack_version" dokploy:"railpackVersion,nullable"` + CleanCache types.Bool `tfsdk:"clean_cache" dokploy:"cleanCache,nullable"` + + // Runtime + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + BuildArgs types.String `tfsdk:"build_args" dokploy:"buildArgs,nullable"` + BuildSecrets types.String `tfsdk:"build_secrets" dokploy:"buildSecrets,nullable"` + CreateEnvFile types.Bool `tfsdk:"create_env_file" dokploy:"createEnvFile"` + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + Title types.String `tfsdk:"title" dokploy:"title,nullable"` + Subtitle types.String `tfsdk:"subtitle" dokploy:"subtitle,nullable"` + Enabled types.Bool `tfsdk:"enabled" dokploy:"enabled,nullable"` + + RegistryID types.String `tfsdk:"registry_id" dokploy:"registryId,nullable"` + RollbackRegistryID types.String `tfsdk:"rollback_registry_id" dokploy:"rollbackRegistryId,nullable"` + BuildRegistryID types.String `tfsdk:"build_registry_id" dokploy:"buildRegistryId,nullable"` + BuildServerID types.String `tfsdk:"build_server_id" dokploy:"buildServerId,nullable"` + RollbackActive types.Bool `tfsdk:"rollback_active" dokploy:"rollbackActive,nullable"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + // Docker Swarm service settings, expressed as JSON documents. + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + // Preview deployments + PreviewActive types.Bool `tfsdk:"is_preview_deployments_active" dokploy:"isPreviewDeploymentsActive,nullable"` + PreviewEnv types.String `tfsdk:"preview_env" dokploy:"previewEnv,nullable"` + PreviewBuildArgs types.String `tfsdk:"preview_build_args" dokploy:"previewBuildArgs,nullable"` + PreviewBuildSecrets types.String `tfsdk:"preview_build_secrets" dokploy:"previewBuildSecrets,nullable"` + PreviewWildcard types.String `tfsdk:"preview_wildcard" dokploy:"previewWildcard,nullable"` + PreviewPort types.Int64 `tfsdk:"preview_port" dokploy:"previewPort,nullable"` + PreviewHTTPS types.Bool `tfsdk:"preview_https" dokploy:"previewHttps,nullable"` + PreviewPath types.String `tfsdk:"preview_path" dokploy:"previewPath,nullable"` + PreviewCertType types.String `tfsdk:"preview_certificate_type" dokploy:"previewCertificateType,nullable"` + PreviewCertResolver types.String `tfsdk:"preview_custom_cert_resolver" dokploy:"previewCustomCertResolver,nullable"` + PreviewLimit types.Int64 `tfsdk:"preview_limit" dokploy:"previewLimit,nullable"` + PreviewLabels types.List `tfsdk:"preview_labels" dokploy:"previewLabels,nullable"` + + // Computed + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func applicationResource() ResourceSpec { + return ResourceSpec{ + Name: "application", + CreateProc: "application.create", + ReadProc: "application.one", + UpdateProc: "application.update", + DeleteProc: "application.delete", + UpdateAfterCreate: true, + NewModel: func() any { return &applicationModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A Dokploy application: a single service built from a Git repository, a Docker " + + "image, or an uploaded artifact.\n\n" + + "Dokploy's `application.create` endpoint accepts only a handful of fields, so this resource creates " + + "the application and then applies the rest of the configuration through `application.update`.\n\n" + + "~> Creating this resource does **not** deploy the application. Trigger a deployment from the " + + "Dokploy UI, the CLI, or a `terraform_data` provisioner calling `application.deploy`.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique application identifier."), + "name": requiredString("Display name of the application."), + "app_name": optionalComputedReplaceString("Unique Docker service name. Generated by Dokploy when " + + "omitted. Changing it forces a new application."), + "description": optionalString("Free-form description."), + "environment_id": requiredReplaceString("Environment this application belongs to."), + "server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host " + + "itself."), + + "source_type": enumString("Where the application's code or image comes from.", sourceTypes, false), + "docker_image": optionalString("Docker image reference, when `source_type` is `docker`."), + "username": optionalString("Registry username, when pulling a private image."), + "password": sensitiveString("Registry password, when pulling a private image.", false), + "registry_url": optionalString("Registry URL, when pulling a private image."), + + "repository": optionalString("GitHub repository name."), + "owner": optionalString("GitHub repository owner."), + "branch": optionalString("GitHub branch to build."), + "build_path": optionalComputedString("Path within the GitHub repository to build from."), + "github_id": optionalString("ID of the configured GitHub provider connection."), + "trigger_type": enumString("What triggers an automatic GitHub deployment.", triggerTypes, false), + "custom_git_url": optionalString("Git remote URL, when `source_type` is `git`."), + "custom_git_branch": optionalString("Branch to build for a custom Git remote."), + "custom_git_build_path": optionalString("Path within a custom Git repository to build from."), + "custom_git_ssh_key_id": optionalString("SSH key used to clone a private custom Git remote."), + + "gitlab_id": optionalString("ID of the configured GitLab provider connection."), + "gitlab_project_id": schema.Int64Attribute{Optional: true, MarkdownDescription: "Numeric GitLab project ID."}, + "gitlab_repository": optionalString("GitLab repository name."), + "gitlab_owner": optionalString("GitLab repository owner."), + "gitlab_branch": optionalString("GitLab branch to build."), + "gitlab_build_path": optionalComputedString("Path within the GitLab repository to build from."), + "gitlab_path_namespace": optionalString("Full GitLab namespace path."), + + "gitea_id": optionalString("ID of the configured Gitea provider connection."), + "gitea_repository": optionalString("Gitea repository name."), + "gitea_owner": optionalString("Gitea repository owner."), + "gitea_branch": optionalString("Gitea branch to build."), + "gitea_build_path": optionalComputedString("Path within the Gitea repository to build from."), + + "bitbucket_id": optionalString("ID of the configured Bitbucket provider connection."), + "bitbucket_repository": optionalString("Bitbucket repository name."), + "bitbucket_repository_slug": optionalString("Bitbucket repository slug."), + "bitbucket_owner": optionalString("Bitbucket repository owner."), + "bitbucket_branch": optionalString("Bitbucket branch to build."), + "bitbucket_build_path": optionalComputedString("Path within the Bitbucket repository to build from."), + + "enable_submodules": optionalComputedBool("Clone Git submodules when checking out the repository."), + "watch_paths": optionalComputedStringList("Glob patterns that limit which changed paths trigger " + + "an automatic deployment."), + "auto_deploy": optionalComputedBool("Deploy automatically when the configured trigger fires."), + + "build_type": enumString("How the application is built.", buildTypes, false), + "dockerfile": optionalComputedString("Path to the Dockerfile, when `build_type` is `dockerfile`."), + "docker_context_path": optionalString("Docker build context path."), + "docker_build_stage": optionalString("Target stage for a multi-stage Docker build."), + "publish_directory": optionalString("Directory served when `build_type` is `static`."), + "is_static_spa": optionalComputedBool("Serve a static build as a single-page application."), + "heroku_version": optionalComputedString("Heroku buildpack stack version."), + "railpack_version": optionalComputedString("Railpack version."), + "clean_cache": optionalComputedBool("Discard the build cache on the next deployment."), + + "env": optionalString("Runtime environment variables in `KEY=value` format, one per line."), + "build_args": optionalString("Docker build arguments in `KEY=value` format, one per line."), + "build_secrets": sensitiveString("Docker build secrets in `KEY=value` format, one per line.", false), + "create_env_file": optionalComputedBool("Write the environment variables to a `.env` file in the " + + "container."), + "command": optionalString("Override the container entrypoint command."), + "args": optionalComputedStringList("Arguments appended to the container command."), + "replicas": optionalComputedInt("Number of replicas to run."), + "memory_reservation": optionalString("Soft memory reservation, for example `256m`."), + "memory_limit": optionalString("Hard memory limit, for example `512m`."), + "cpu_reservation": optionalString("Soft CPU reservation, for example `0.5`."), + "cpu_limit": optionalString("Hard CPU limit, for example `1`."), + "title": optionalString("Display title shown in the Dokploy UI."), + "subtitle": optionalString("Display subtitle shown in the Dokploy UI."), + "enabled": optionalComputedBool("Whether the application is enabled."), + + "registry_id": optionalString("Registry used to push the built image."), + "rollback_registry_id": optionalString("Registry used to store rollback images."), + "build_registry_id": optionalString("Registry used by a dedicated build server."), + "build_server_id": optionalString("Server that performs builds, when separate from the deploy server."), + "rollback_active": optionalComputedBool("Keep previous images so deployments can be rolled back."), + + "network_ids": optionalComputedStringList("IDs of additional Docker networks to attach."), + "detach_dokploy_network": optionalComputedBool("Detach the service from the shared `dokploy-network`."), + + "health_check_swarm": optionalJSON("Docker Swarm health check configuration, as a JSON object."), + "restart_policy_swarm": optionalJSON("Docker Swarm restart policy, as a JSON object."), + "placement_swarm": optionalJSON("Docker Swarm placement constraints, as a JSON object."), + "update_config_swarm": optionalJSON("Docker Swarm rolling update configuration, as a JSON object."), + "rollback_config_swarm": optionalJSON("Docker Swarm rollback configuration, as a JSON object."), + "mode_swarm": optionalJSON("Docker Swarm service mode, as a JSON object."), + "labels_swarm": optionalJSON("Docker Swarm service labels, as a JSON object."), + "network_swarm": optionalJSON("Docker Swarm network attachments, as a JSON array."), + "endpoint_spec_swarm": optionalJSON("Docker Swarm endpoint specification, as a JSON object."), + "ulimits_swarm": optionalJSON("Docker Swarm ulimits, as a JSON object."), + "stop_grace_period_swarm": schema.Int64Attribute{Optional: true, MarkdownDescription: "Grace period in nanoseconds before a container is killed."}, + + "is_preview_deployments_active": optionalComputedBool("Build a preview deployment for each pull request."), + "preview_env": optionalString("Environment variables applied to preview deployments."), + "preview_build_args": optionalString("Build arguments applied to preview deployments."), + "preview_build_secrets": sensitiveString("Build secrets applied to preview deployments.", false), + "preview_wildcard": optionalString("Wildcard domain used to expose preview deployments."), + "preview_port": optionalComputedInt("Container port exposed by preview deployments."), + "preview_https": optionalComputedBool("Serve preview deployments over HTTPS."), + "preview_path": optionalComputedString("Base path for preview deployments."), + "preview_certificate_type": enumString("Certificate strategy for preview deployments.", certificateTypes, false), + "preview_custom_cert_resolver": optionalString("Traefik certificate resolver for preview deployments."), + "preview_limit": optionalComputedInt("Maximum number of concurrent preview deployments."), + "preview_labels": optionalComputedStringList("Pull request labels that opt into preview deployments."), + + "application_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."), + "created_at": computedString("RFC 3339 timestamp of when the application was created."), + }, + }, + } +} diff --git a/internal/provider/resource_compose.go b/internal/provider/resource_compose.go new file mode 100644 index 0000000..040bb76 --- /dev/null +++ b/internal/provider/resource_compose.go @@ -0,0 +1,144 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type composeModel struct { + ID types.String `tfsdk:"id" dokploy:"composeId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + ComposeType types.String `tfsdk:"compose_type" dokploy:"composeType"` + ComposeFile types.String `tfsdk:"compose_file" dokploy:"composeFile"` + ComposePath types.String `tfsdk:"compose_path" dokploy:"composePath"` + SourceType types.String `tfsdk:"source_type" dokploy:"sourceType"` + Command types.String `tfsdk:"command" dokploy:"command"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + + Repository types.String `tfsdk:"repository" dokploy:"repository,nullable"` + Owner types.String `tfsdk:"owner" dokploy:"owner,nullable"` + Branch types.String `tfsdk:"branch" dokploy:"branch,nullable"` + GithubID types.String `tfsdk:"github_id" dokploy:"githubId,nullable"` + GitlabID types.String `tfsdk:"gitlab_id" dokploy:"gitlabId,nullable"` + GitlabProjectID types.Int64 `tfsdk:"gitlab_project_id" dokploy:"gitlabProjectId,nullable"` + GitlabRepository types.String `tfsdk:"gitlab_repository" dokploy:"gitlabRepository,nullable"` + GitlabOwner types.String `tfsdk:"gitlab_owner" dokploy:"gitlabOwner,nullable"` + GitlabBranch types.String `tfsdk:"gitlab_branch" dokploy:"gitlabBranch,nullable"` + GitlabNamespace types.String `tfsdk:"gitlab_path_namespace" dokploy:"gitlabPathNamespace,nullable"` + GiteaID types.String `tfsdk:"gitea_id" dokploy:"giteaId,nullable"` + GiteaRepository types.String `tfsdk:"gitea_repository" dokploy:"giteaRepository,nullable"` + GiteaOwner types.String `tfsdk:"gitea_owner" dokploy:"giteaOwner,nullable"` + GiteaBranch types.String `tfsdk:"gitea_branch" dokploy:"giteaBranch,nullable"` + BitbucketID types.String `tfsdk:"bitbucket_id" dokploy:"bitbucketId,nullable"` + BitbucketRepo types.String `tfsdk:"bitbucket_repository" dokploy:"bitbucketRepository,nullable"` + BitbucketSlug types.String `tfsdk:"bitbucket_repository_slug" dokploy:"bitbucketRepositorySlug,nullable"` + BitbucketOwner types.String `tfsdk:"bitbucket_owner" dokploy:"bitbucketOwner,nullable"` + BitbucketBranch types.String `tfsdk:"bitbucket_branch" dokploy:"bitbucketBranch,nullable"` + CustomGitURL types.String `tfsdk:"custom_git_url" dokploy:"customGitUrl,nullable"` + CustomGitBranch types.String `tfsdk:"custom_git_branch" dokploy:"customGitBranch,nullable"` + CustomGitSSHKey types.String `tfsdk:"custom_git_ssh_key_id" dokploy:"customGitSSHKeyId,nullable"` + + EnableSubmodules types.Bool `tfsdk:"enable_submodules" dokploy:"enableSubmodules"` + AutoDeploy types.Bool `tfsdk:"auto_deploy" dokploy:"autoDeploy,nullable"` + TriggerType types.String `tfsdk:"trigger_type" dokploy:"triggerType,nullable"` + WatchPaths types.List `tfsdk:"watch_paths" dokploy:"watchPaths,nullable"` + + Suffix types.String `tfsdk:"suffix" dokploy:"suffix"` + Randomize types.Bool `tfsdk:"randomize" dokploy:"randomize"` + IsolatedDeployment types.Bool `tfsdk:"isolated_deployment" dokploy:"isolatedDeployment"` + IsolatedDeployVolue types.Bool `tfsdk:"isolated_deployments_volume" dokploy:"isolatedDeploymentsVolume"` + + ComposeStatus types.String `tfsdk:"compose_status" dokploy:"composeStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` + + // Terraform-only: controls the destroy call, never sent on create/update. + DeleteVolumes types.Bool `tfsdk:"delete_volumes"` +} + +func composeResource() ResourceSpec { + return ResourceSpec{ + Name: "compose", + CreateProc: "compose.create", + ReadProc: "compose.one", + UpdateProc: "compose.update", + DeleteProc: "compose.delete", + UpdateAfterCreate: true, + NewModel: func() any { return &composeModel{} }, + DeleteBody: func(model any) map[string]any { + // Dokploy requires this field on every compose delete. + deleteVolumes := false + if compose, ok := model.(*composeModel); ok && compose.DeleteVolumes.ValueBool() { + deleteVolumes = true + } + return map[string]any{"deleteVolumes": deleteVolumes} + }, + Schema: schema.Schema{ + MarkdownDescription: "A Docker Compose or Docker Swarm stack managed by Dokploy.\n\n" + + "Set `compose_file` to manage the stack definition inline (with `source_type = \"raw\"`), or point " + + "the stack at a Git repository and set `compose_path`.\n\n" + + "~> Creating this resource does **not** deploy the stack.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique compose identifier."), + "name": requiredString("Display name of the stack."), + "app_name": optionalComputedReplaceString("Unique Docker stack name. Generated by Dokploy when " + + "omitted. Changing it forces a new stack."), + "description": optionalString("Free-form description."), + "environment_id": requiredReplaceString("Environment this stack belongs to."), + "server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host itself."), + + "compose_type": enumString("Whether to run the stack with Docker Compose or Docker Swarm.", composeTypes, false), + "compose_file": optionalComputedString("Inline Compose file contents. Used when `source_type` is `raw`."), + "compose_path": optionalComputedString("Path to the Compose file within the repository."), + "source_type": enumString("Where the Compose file comes from.", composeSources, false), + "command": optionalComputedString("Custom `docker compose` command to run."), + "env": optionalString("Environment variables in `KEY=value` format, one per line."), + + "repository": optionalString("GitHub repository name."), + "owner": optionalString("GitHub repository owner."), + "branch": optionalString("GitHub branch to deploy."), + "github_id": optionalString("ID of the configured GitHub provider connection."), + "gitlab_id": optionalString("ID of the configured GitLab provider connection."), + "gitlab_project_id": schema.Int64Attribute{Optional: true, MarkdownDescription: "Numeric GitLab project ID."}, + "gitlab_repository": optionalString("GitLab repository name."), + "gitlab_owner": optionalString("GitLab repository owner."), + "gitlab_branch": optionalString("GitLab branch to deploy."), + "gitlab_path_namespace": optionalString("Full GitLab namespace path."), + "gitea_id": optionalString("ID of the configured Gitea provider connection."), + "gitea_repository": optionalString("Gitea repository name."), + "gitea_owner": optionalString("Gitea repository owner."), + "gitea_branch": optionalString("Gitea branch to deploy."), + "bitbucket_id": optionalString("ID of the configured Bitbucket provider connection."), + "bitbucket_repository": optionalString("Bitbucket repository name."), + "bitbucket_repository_slug": optionalString("Bitbucket repository slug."), + "bitbucket_owner": optionalString("Bitbucket repository owner."), + "bitbucket_branch": optionalString("Bitbucket branch to deploy."), + "custom_git_url": optionalString("Git remote URL, when `source_type` is `git`."), + "custom_git_branch": optionalString("Branch to deploy for a custom Git remote."), + "custom_git_ssh_key_id": optionalString("SSH key used to clone a private custom Git remote."), + + "enable_submodules": optionalComputedBool("Clone Git submodules when checking out the repository."), + "auto_deploy": optionalComputedBool("Deploy automatically when the configured trigger fires."), + "trigger_type": enumString("What triggers an automatic deployment.", triggerTypes, false), + "watch_paths": optionalComputedStringList("Glob patterns that limit which changed paths trigger " + + "an automatic deployment."), + + "suffix": optionalComputedString("Suffix appended to generated resource names."), + "randomize": optionalComputedBool("Append a random suffix to service and volume names."), + "isolated_deployment": optionalComputedBool("Run the stack on its own isolated Docker network."), + "isolated_deployments_volume": optionalComputedBool("Prefix volume names for isolated deployments. " + + "Retained for backwards compatibility."), + + "compose_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."), + "created_at": computedString("RFC 3339 timestamp of when the stack was created."), + + "delete_volumes": optionalBool("Whether to delete the stack's Docker volumes when this resource is " + + "destroyed. Defaults to `false`, which preserves the data."), + }, + }, + } +} diff --git a/internal/provider/resource_databases.go b/internal/provider/resource_databases.go new file mode 100644 index 0000000..ccaf8e0 --- /dev/null +++ b/internal/provider/resource_databases.go @@ -0,0 +1,406 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// The five managed databases share almost their entire surface. The Terraform +// Plugin Framework cannot reflect into embedded structs, so each model is +// written out flat, but the schema attributes are assembled from one place. + +// databaseCommonSchema returns the attributes every database resource exposes. +// engine is used to word the descriptions. +func databaseCommonSchema(engine string) map[string]schema.Attribute { + return map[string]schema.Attribute{ + "id": computedID("Unique " + engine + " identifier."), + "name": requiredString("Display name of the database."), + "app_name": optionalComputedReplaceString("Unique Docker service name. Generated by Dokploy when omitted. " + + "Changing it forces a new database."), + "description": optionalString("Free-form description."), + "environment_id": requiredReplaceString("Environment this database belongs to."), + "server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host itself."), + + "command": optionalString("Override the container entrypoint command."), + "args": optionalComputedStringList("Arguments appended to the container command."), + "env": optionalString("Environment variables in `KEY=value` format, one per line."), + "memory_reservation": optionalString("Soft memory reservation, for example `256m`."), + "memory_limit": optionalString("Hard memory limit, for example `512m`."), + "cpu_reservation": optionalString("Soft CPU reservation, for example `0.5`."), + "cpu_limit": optionalString("Hard CPU limit, for example `1`."), + "external_port": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "Host port to expose the database on. Leave unset to keep the database reachable " + + "only from inside the Docker network.", + }, + "replicas": optionalComputedInt("Number of replicas to run."), + + "network_ids": optionalComputedStringList("IDs of additional Docker networks to attach."), + "detach_dokploy_network": optionalComputedBool("Detach the service from the shared `dokploy-network`."), + + "health_check_swarm": optionalJSON("Docker Swarm health check configuration, as a JSON object."), + "restart_policy_swarm": optionalJSON("Docker Swarm restart policy, as a JSON object."), + "placement_swarm": optionalJSON("Docker Swarm placement constraints, as a JSON object."), + "update_config_swarm": optionalJSON("Docker Swarm rolling update configuration, as a JSON object."), + "rollback_config_swarm": optionalJSON("Docker Swarm rollback configuration, as a JSON object."), + "mode_swarm": optionalJSON("Docker Swarm service mode, as a JSON object."), + "labels_swarm": optionalJSON("Docker Swarm service labels, as a JSON object."), + "network_swarm": optionalJSON("Docker Swarm network attachments, as a JSON array."), + "endpoint_spec_swarm": optionalJSON("Docker Swarm endpoint specification, as a JSON object."), + "ulimits_swarm": optionalJSON("Docker Swarm ulimits, as a JSON object."), + "stop_grace_period_swarm": schema.Int64Attribute{Optional: true, MarkdownDescription: "Grace period in nanoseconds before a container is killed."}, + + "application_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."), + "created_at": computedString("RFC 3339 timestamp of when the database was created."), + } +} + +func withAttributes(base map[string]schema.Attribute, extra map[string]schema.Attribute) map[string]schema.Attribute { + for name, attribute := range extra { + base[name] = attribute + } + return base +} + +func databaseNote(engine string) string { + return "A managed " + engine + " instance running on Dokploy.\n\n" + + "~> Creating this resource provisions the service definition but does **not** start a deployment. " + + "Deploy it from the Dokploy UI or CLI.\n\n" + + "~> Credentials are stored in Terraform state. Use a state backend with encryption at rest." +} + +// ---------------------------------------------------------------- PostgreSQL + +type postgresModel struct { + ID types.String `tfsdk:"id" dokploy:"postgresId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"` + DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"` + DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` + + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func postgresResource() ResourceSpec { + return ResourceSpec{ + Name: "postgres", + // The create endpoint accepts only the core fields; everything else + // (limits, env, swarm settings) has to be written through update. + UpdateAfterCreate: true, + CreateProc: "postgres.create", + ReadProc: "postgres.one", + UpdateProc: "postgres.update", + DeleteProc: "postgres.remove", + NewModel: func() any { return &postgresModel{} }, + Schema: schema.Schema{ + MarkdownDescription: databaseNote("PostgreSQL"), + Attributes: withAttributes(databaseCommonSchema("postgres"), map[string]schema.Attribute{ + "database_name": requiredString("Name of the database to create."), + "database_user": requiredString("Database user to create."), + "database_password": sensitiveString("Password for the database user.", true), + "docker_image": requiredString("PostgreSQL image to run, for example `postgres:16`."), + }), + }, + } +} + +// --------------------------------------------------------------------- MySQL + +type mysqlModel struct { + ID types.String `tfsdk:"id" dokploy:"mysqlId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"` + DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"` + DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` + DatabaseRootPass types.String `tfsdk:"database_root_password" dokploy:"databaseRootPassword"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` + + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func mysqlResource() ResourceSpec { + return ResourceSpec{ + Name: "mysql", + // The create endpoint accepts only the core fields; everything else + // (limits, env, swarm settings) has to be written through update. + UpdateAfterCreate: true, + CreateProc: "mysql.create", + ReadProc: "mysql.one", + UpdateProc: "mysql.update", + DeleteProc: "mysql.remove", + NewModel: func() any { return &mysqlModel{} }, + Schema: schema.Schema{ + MarkdownDescription: databaseNote("MySQL"), + Attributes: withAttributes(databaseCommonSchema("mysql"), map[string]schema.Attribute{ + "database_name": requiredString("Name of the database to create."), + "database_user": requiredString("Database user to create."), + "database_password": sensitiveString("Password for the database user.", true), + "database_root_password": sensitiveString("Password for the MySQL `root` user.", true), + "docker_image": requiredString("MySQL image to run, for example `mysql:8`."), + }), + }, + } +} + +// ------------------------------------------------------------------- MariaDB + +type mariadbModel struct { + ID types.String `tfsdk:"id" dokploy:"mariadbId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"` + DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"` + DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` + DatabaseRootPass types.String `tfsdk:"database_root_password" dokploy:"databaseRootPassword"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` + + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func mariadbResource() ResourceSpec { + return ResourceSpec{ + Name: "mariadb", + // The create endpoint accepts only the core fields; everything else + // (limits, env, swarm settings) has to be written through update. + UpdateAfterCreate: true, + CreateProc: "mariadb.create", + ReadProc: "mariadb.one", + UpdateProc: "mariadb.update", + DeleteProc: "mariadb.remove", + NewModel: func() any { return &mariadbModel{} }, + Schema: schema.Schema{ + MarkdownDescription: databaseNote("MariaDB"), + Attributes: withAttributes(databaseCommonSchema("mariadb"), map[string]schema.Attribute{ + "database_name": requiredString("Name of the database to create."), + "database_user": requiredString("Database user to create."), + "database_password": sensitiveString("Password for the database user.", true), + "database_root_password": sensitiveString("Password for the MariaDB `root` user.", true), + "docker_image": requiredString("MariaDB image to run, for example `mariadb:11`."), + }), + }, + } +} + +// ------------------------------------------------------------------- MongoDB + +type mongoModel struct { + ID types.String `tfsdk:"id" dokploy:"mongoId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"` + DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` + ReplicaSets types.Bool `tfsdk:"replica_sets" dokploy:"replicaSets,nullable"` + + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func mongoResource() ResourceSpec { + return ResourceSpec{ + Name: "mongo", + // The create endpoint accepts only the core fields; everything else + // (limits, env, swarm settings) has to be written through update. + UpdateAfterCreate: true, + CreateProc: "mongo.create", + ReadProc: "mongo.one", + UpdateProc: "mongo.update", + DeleteProc: "mongo.remove", + NewModel: func() any { return &mongoModel{} }, + Schema: schema.Schema{ + MarkdownDescription: databaseNote("MongoDB"), + Attributes: withAttributes(databaseCommonSchema("mongo"), map[string]schema.Attribute{ + "database_user": requiredString("Root username to create."), + "database_password": sensitiveString("Password for the root user.", true), + "docker_image": optionalComputedString("MongoDB image to run. Defaults to `mongo:8`."), + "replica_sets": optionalComputedBool("Start the instance as a single-node replica set."), + }), + }, + } +} + +// --------------------------------------------------------------------- Redis + +type redisModel struct { + ID types.String `tfsdk:"id" dokploy:"redisId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + AppName types.String `tfsdk:"app_name" dokploy:"appName"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + + DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` + DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` + + Command types.String `tfsdk:"command" dokploy:"command,nullable"` + Args types.List `tfsdk:"args" dokploy:"args,nullable"` + Env types.String `tfsdk:"env" dokploy:"env,nullable"` + MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` + MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` + CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` + CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` + ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + + NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` + DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` + + HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` + RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` + PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` + UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` + RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` + ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` + LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` + NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` + EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` + UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"` + StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` + + ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func redisResource() ResourceSpec { + return ResourceSpec{ + Name: "redis", + // The create endpoint accepts only the core fields; everything else + // (limits, env, swarm settings) has to be written through update. + UpdateAfterCreate: true, + CreateProc: "redis.create", + ReadProc: "redis.one", + UpdateProc: "redis.update", + DeleteProc: "redis.remove", + NewModel: func() any { return &redisModel{} }, + Schema: schema.Schema{ + MarkdownDescription: databaseNote("Redis"), + Attributes: withAttributes(databaseCommonSchema("redis"), map[string]schema.Attribute{ + "database_password": sensitiveString("Password used to authenticate to Redis.", true), + "docker_image": requiredString("Redis image to run, for example `redis:7`."), + }), + }, + } +} diff --git a/internal/provider/resource_environment.go b/internal/provider/resource_environment.go new file mode 100644 index 0000000..74d7475 --- /dev/null +++ b/internal/provider/resource_environment.go @@ -0,0 +1,49 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type environmentModel struct { + ID types.String `tfsdk:"id" dokploy:"environmentId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + // Not `nullable`: environment.create and environment.update are hand-written + // Zod objects where description is `.optional()` but not `.nullable()`, so + // an explicit null is rejected and the key must simply be omitted. + Description types.String `tfsdk:"description" dokploy:"description"` + ProjectID types.String `tfsdk:"project_id" dokploy:"projectId"` + Env types.String `tfsdk:"env" dokploy:"env,update"` + IsDefault types.Bool `tfsdk:"is_default" dokploy:"isDefault,ro"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func environmentResource() ResourceSpec { + return ResourceSpec{ + Name: "environment", + CreateProc: "environment.create", + ReadProc: "environment.one", + UpdateProc: "environment.update", + DeleteProc: "environment.remove", + NewModel: func() any { return &environmentModel{} }, + // `environment.create` accepts only name, description and projectId, + // so `env` has to be written through a follow-up update. + UpdateAfterCreate: true, + Schema: schema.Schema{ + MarkdownDescription: "An environment inside a Dokploy project, such as `staging` or `production`. " + + "Services belong to an environment rather than directly to a project.\n\n" + + "Dokploy creates a default `production` environment with every project; reference it via " + + "`dokploy_project..default_environment_id` instead of declaring it here.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique environment identifier."), + "name": requiredString("Environment name, for example `staging`."), + "description": optionalString("Free-form description."), + "project_id": requiredReplaceString("Project this environment belongs to."), + "env": optionalComputedString("Environment-wide variables in `KEY=value` format, one per line. " + + "These are merged into every service in this environment."), + "is_default": computedBool("Whether this is the project's default environment."), + "created_at": computedString("RFC 3339 timestamp of when the environment was created."), + }, + }, + } +} diff --git a/internal/provider/resource_networking.go b/internal/provider/resource_networking.go new file mode 100644 index 0000000..8ceab22 --- /dev/null +++ b/internal/provider/resource_networking.go @@ -0,0 +1,254 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" +) + +// -------------------------------------------------------------------- Domain + +type domainModel struct { + ID types.String `tfsdk:"id" dokploy:"domainId,id"` + Host types.String `tfsdk:"host" dokploy:"host"` + Path types.String `tfsdk:"path" dokploy:"path,nullable"` + Port types.Int64 `tfsdk:"port" dokploy:"port,nullable"` + HTTPS types.Bool `tfsdk:"https" dokploy:"https"` + CertificateType types.String `tfsdk:"certificate_type" dokploy:"certificateType"` + CustomCertResolver types.String `tfsdk:"custom_cert_resolver" dokploy:"customCertResolver,nullable"` + CustomEntrypoint types.String `tfsdk:"custom_entrypoint" dokploy:"customEntrypoint,nullable"` + DomainType types.String `tfsdk:"domain_type" dokploy:"domainType,nullable"` + ServiceName types.String `tfsdk:"service_name" dokploy:"serviceName,nullable"` + InternalPath types.String `tfsdk:"internal_path" dokploy:"internalPath,nullable"` + StripPath types.Bool `tfsdk:"strip_path" dokploy:"stripPath"` + Middlewares types.List `tfsdk:"middlewares" dokploy:"middlewares"` + ForwardAuthEnabled types.Bool `tfsdk:"forward_auth_enabled" dokploy:"forwardAuthEnabled"` + ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"` + ComposeID types.String `tfsdk:"compose_id" dokploy:"composeId,create"` + PreviewDeploymentID types.String `tfsdk:"preview_deployment_id" dokploy:"previewDeploymentId,create"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func domainResource() ResourceSpec { + return ResourceSpec{ + Name: "domain", + CreateProc: "domain.create", + ReadProc: "domain.one", + UpdateProc: "domain.update", + DeleteProc: "domain.delete", + NewModel: func() any { return &domainModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A domain routed to an application or a Compose service through Dokploy's " + + "Traefik instance.\n\n" + + "Set exactly one of `application_id` or `compose_id`. When targeting a Compose stack, " + + "`service_name` selects which service in the stack receives the traffic.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique domain identifier."), + "host": requiredString("Fully-qualified hostname, for example `api.example.com`."), + "path": optionalComputedString("Path prefix this domain routes, defaults to `/`."), + "port": optionalComputedInt("Container port that receives the traffic, defaults to `3000`."), + "https": optionalComputedBool("Serve the domain over HTTPS and redirect HTTP traffic to it."), + "certificate_type": enumString( + "How TLS certificates are obtained. Use `letsencrypt` for automatic certificates.", + certificateTypes, false), + "custom_cert_resolver": optionalString("Traefik certificate resolver name, when " + + "`certificate_type` is `custom`."), + "custom_entrypoint": optionalString("Traefik entrypoint to bind, when not using the defaults."), + "domain_type": enumString("What kind of target this domain points at.", domainTypes, false), + "service_name": optionalString("Name of the service inside a Compose stack that receives the " + + "traffic. Required when `compose_id` is set."), + "internal_path": optionalComputedString("Path the request is rewritten to before it reaches the " + + "container, defaults to `/`."), + "strip_path": optionalComputedBool("Strip `path` from the request before forwarding it."), + "middlewares": optionalComputedStringList("Names of Traefik middlewares to apply."), + "forward_auth_enabled": optionalComputedBool("Protect this domain with Dokploy's forward auth."), + "application_id": optionalReplaceString("Application this domain routes to."), + "compose_id": optionalReplaceString("Compose stack this domain routes to."), + "preview_deployment_id": optionalReplaceString("Preview deployment this domain routes to."), + "created_at": computedString("RFC 3339 timestamp of when the domain was created."), + }, + }, + } +} + +// --------------------------------------------------------------------- Mount + +type mountModel struct { + ID types.String `tfsdk:"id" dokploy:"mountId,id"` + Type types.String `tfsdk:"type" dokploy:"type"` + MountPath types.String `tfsdk:"mount_path" dokploy:"mountPath"` + HostPath types.String `tfsdk:"host_path" dokploy:"hostPath,nullable"` + VolumeName types.String `tfsdk:"volume_name" dokploy:"volumeName,nullable"` + FilePath types.String `tfsdk:"file_path" dokploy:"filePath,nullable"` + Content types.String `tfsdk:"content" dokploy:"content,nullable"` + ServiceType types.String `tfsdk:"service_type" dokploy:"serviceType"` + + // serviceId is only accepted on create; reads return the concrete + // applicationId/composeId/... column instead, so it is never refreshed. + ServiceID types.String `tfsdk:"service_id" dokploy:"serviceId,create"` +} + +func mountResource() ResourceSpec { + return ResourceSpec{ + Name: "mount", + CreateProc: "mounts.create", + ReadProc: "mounts.one", + UpdateProc: "mounts.update", + DeleteProc: "mounts.remove", + NewModel: func() any { return &mountModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A volume, bind mount, or config file attached to a Dokploy service.\n\n" + + "* `type = \"volume\"` — a named Docker volume; set `volume_name`.\n" + + "* `type = \"bind\"` — a path on the host; set `host_path`.\n" + + "* `type = \"file\"` — a file rendered from `content`; set `file_path`.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique mount identifier."), + "type": enumString("The kind of mount to create.", mountTypes, true), + "mount_path": requiredString("Path inside the container where the mount appears."), + "host_path": optionalString("Path on the host, when `type` is `bind`."), + "volume_name": optionalString("Name of the Docker volume, when `type` is `volume`."), + "file_path": optionalString("Path of the generated file, when `type` is `file`."), + "content": optionalString("Contents of the generated file, when `type` is `file`."), + "service_type": enumString("The kind of service this mount attaches to.", serviceTypes, true), + "service_id": requiredReplaceString("ID of the service this mount attaches to. Must match " + + "`service_type` — an application ID, a compose ID, a postgres ID, and so on."), + }, + }, + } +} + +// ---------------------------------------------------------------------- Port + +type portModel struct { + ID types.String `tfsdk:"id" dokploy:"portId,id"` + PublishedPort types.Int64 `tfsdk:"published_port" dokploy:"publishedPort"` + TargetPort types.Int64 `tfsdk:"target_port" dokploy:"targetPort"` + Protocol types.String `tfsdk:"protocol" dokploy:"protocol"` + PublishMode types.String `tfsdk:"publish_mode" dokploy:"publishMode"` + ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"` +} + +func portResource() ResourceSpec { + return ResourceSpec{ + Name: "port", + CreateProc: "port.create", + ReadProc: "port.one", + UpdateProc: "port.update", + DeleteProc: "port.delete", + NewModel: func() any { return &portModel{} }, + Schema: schema.Schema{ + MarkdownDescription: "A published port that exposes an application directly on the host, " + + "bypassing Traefik.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique port identifier."), + "published_port": requiredInt("Port exposed on the host."), + "target_port": requiredInt("Port the container listens on."), + "protocol": enumString("Transport protocol.", protocolTypes, true), + "publish_mode": enumString("Docker Swarm publish mode. `host` binds directly to the node; "+ + "`ingress` uses the swarm routing mesh.", publishModes, false), + "application_id": requiredReplaceString("Application this port belongs to."), + }, + }, + } +} + +// ------------------------------------------------------------------ Redirect + +type redirectModel struct { + ID types.String `tfsdk:"id" dokploy:"redirectId,id"` + Regex types.String `tfsdk:"regex" dokploy:"regex"` + Replacement types.String `tfsdk:"replacement" dokploy:"replacement"` + Permanent types.Bool `tfsdk:"permanent" dokploy:"permanent"` + ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func redirectResource() ResourceSpec { + return ResourceSpec{ + Name: "redirect", + CreateProc: "redirects.create", + ReadProc: "redirects.one", + UpdateProc: "redirects.update", + DeleteProc: "redirects.delete", + NewModel: func() any { return &redirectModel{} }, + // `redirects.create` returns `true`, so the new ID is discovered by + // diffing the application's redirect list. + ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) { + redirect, ok := model.(*redirectModel) + if !ok { + return nil, fmt.Errorf("expected *redirectModel, got %T", model) + } + raw, err := api.Query(ctx, "application.one", map[string]any{ + "applicationId": redirect.ApplicationID.ValueString(), + }) + if err != nil { + return nil, err + } + return collectNestedIDs(raw, "redirects", "redirectId") + }, + Schema: schema.Schema{ + MarkdownDescription: "A Traefik redirect rule attached to an application.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique redirect identifier."), + "regex": requiredString("Regular expression matched against the incoming URL."), + "replacement": requiredString("Replacement URL, which may reference capture groups such as `${1}`."), + "permanent": optionalComputedBool("Issue a permanent (301) redirect instead of a temporary " + + "(302) one."), + "application_id": requiredReplaceString("Application this redirect belongs to."), + "created_at": computedString("RFC 3339 timestamp of when the redirect was created."), + }, + }, + } +} + +// ------------------------------------------------------------------ Security + +type securityModel struct { + ID types.String `tfsdk:"id" dokploy:"securityId,id"` + Username types.String `tfsdk:"username" dokploy:"username"` + Password types.String `tfsdk:"password" dokploy:"password,noread"` + ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +} + +func securityResource() ResourceSpec { + return ResourceSpec{ + Name: "security", + CreateProc: "security.create", + ReadProc: "security.one", + UpdateProc: "security.update", + DeleteProc: "security.delete", + NewModel: func() any { return &securityModel{} }, + // `security.create` returns `true`, so the new ID is discovered by + // diffing the application's basic-auth credential list. + ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) { + security, ok := model.(*securityModel) + if !ok { + return nil, fmt.Errorf("expected *securityModel, got %T", model) + } + raw, err := api.Query(ctx, "application.one", map[string]any{ + "applicationId": security.ApplicationID.ValueString(), + }) + if err != nil { + return nil, err + } + return collectNestedIDs(raw, "security", "securityId") + }, + Schema: schema.Schema{ + MarkdownDescription: "HTTP basic authentication credentials protecting an application's domains.\n\n" + + "~> Dokploy stores the password hashed and does not return it. The value in Terraform state is " + + "the one you configured.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique credential identifier."), + "username": requiredString("Basic auth username."), + "password": sensitiveString("Basic auth password.", true), + "application_id": requiredReplaceString("Application these credentials protect."), + "created_at": computedString("RFC 3339 timestamp of when the credentials were created."), + }, + }, + } +} diff --git a/internal/provider/resource_project.go b/internal/provider/resource_project.go new file mode 100644 index 0000000..d85baa1 --- /dev/null +++ b/internal/provider/resource_project.go @@ -0,0 +1,87 @@ +package provider + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type projectModel struct { + ID types.String `tfsdk:"id" dokploy:"projectId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + Description types.String `tfsdk:"description" dokploy:"description,nullable"` + Env types.String `tfsdk:"env" dokploy:"env"` + CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` + OrgID types.String `tfsdk:"organization_id" dokploy:"organizationId,ro"` + + // Derived in PostRead from the nested `environments` array. + DefaultEnvironmentID types.String `tfsdk:"default_environment_id"` +} + +func projectResource() ResourceSpec { + return ResourceSpec{ + Name: "project", + CreateProc: "project.create", + ReadProc: "project.one", + UpdateProc: "project.update", + DeleteProc: "project.remove", + CreateResponseKey: "project", + NewModel: func() any { return &projectModel{} }, + PostRead: projectPostRead, + Schema: schema.Schema{ + MarkdownDescription: "A Dokploy project: the top-level container for environments and services.\n\n" + + "Creating a project automatically creates a default `production` environment. Its ID is exposed as " + + "`default_environment_id`, so services can be attached without declaring a separate " + + "`dokploy_environment` resource.", + Attributes: map[string]schema.Attribute{ + "id": computedID("Unique project identifier."), + "name": requiredString("Display name of the project."), + "description": optionalString("Free-form description."), + "env": optionalComputedString("Project-wide environment variables in `KEY=value` format, one per " + + "line. These are shared with every service in the project."), + "created_at": computedString("RFC 3339 timestamp of when the project was created."), + "organization_id": computedString("Organization that owns the project."), + "default_environment_id": computedString("ID of the `production` environment that Dokploy creates " + + "automatically with the project."), + }, + }, + } +} + +// projectPostRead extracts the default environment from a `project.one` +// response so it can be referenced directly. +func projectPostRead(raw json.RawMessage, model any) error { + project, ok := model.(*projectModel) + if !ok { + return fmt.Errorf("expected *projectModel, got %T", model) + } + + var payload struct { + Environments []struct { + EnvironmentID string `json:"environmentId"` + Name string `json:"name"` + IsDefault bool `json:"isDefault"` + } `json:"environments"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("decoding project environments: %w", err) + } + + project.DefaultEnvironmentID = types.StringNull() + for _, env := range payload.Environments { + if env.IsDefault { + project.DefaultEnvironmentID = types.StringValue(env.EnvironmentID) + return nil + } + } + // Fall back to a conventionally named environment if none is flagged. + for _, env := range payload.Environments { + if env.Name == "production" { + project.DefaultEnvironmentID = types.StringValue(env.EnvironmentID) + return nil + } + } + return nil +} diff --git a/internal/provider/schema_helpers.go b/internal/provider/schema_helpers.go new file mode 100644 index 0000000..3639dd4 --- /dev/null +++ b/internal/provider/schema_helpers.go @@ -0,0 +1,223 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Shorthand schema constructors. Dokploy assigns server-side defaults to most +// optional fields, so nearly everything optional is also Computed: that lets +// the API supply a value without Terraform reporting an inconsistent result. + +func computedID(description string) schema.StringAttribute { + return schema.StringAttribute{ + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + } +} + +func requiredString(description string) schema.StringAttribute { + return schema.StringAttribute{Required: true, MarkdownDescription: description} +} + +// requiredReplaceString is a required attribute that cannot be changed in +// place; Dokploy has no API to move the resource, so Terraform recreates it. +func requiredReplaceString(description string) schema.StringAttribute { + return schema.StringAttribute{ + Required: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + } +} + +func optionalReplaceString(description string) schema.StringAttribute { + return schema.StringAttribute{ + Optional: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + } +} + +// requiresReplaceString is the plan-modifier list for an attribute that cannot +// be changed in place. +func requiresReplaceString() []planmodifier.String { + return []planmodifier.String{stringplanmodifier.RequiresReplace()} +} + +func optionalString(description string) schema.StringAttribute { + return schema.StringAttribute{Optional: true, MarkdownDescription: description} +} + +// computedString is a server-assigned value that Terraform never sends. +// +// UseStateForUnknown is essential here: without it every computed attribute is +// planned as unknown during an update, and any attribute referencing one -- +// `environment_id = dokploy_project.x.default_environment_id`, say -- would +// then be unknown too, forcing a spurious replacement of the dependent +// resource. Read still refreshes these values, so genuine drift is detected. +func computedString(description string) schema.StringAttribute { + return schema.StringAttribute{ + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + } +} + +// optionalComputedString is the workhorse: settable by the practitioner, and +// defaulted by Dokploy when omitted. +// +// UseStateForUnknown keeps plans readable. Without it the framework replans +// every unconfigured Optional+Computed attribute as unknown as soon as +// anything else on the resource changes, so a one-line edit renders as a dozen +// "(known after apply)" lines. Dokploy assigns these defaults once at create +// and then stores them, so reusing the prior value is accurate -- and Read +// still refreshes them, so real drift is still caught. +func optionalComputedString(description string) schema.StringAttribute { + return schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + } +} + +// optionalComputedReplaceString is defaulted by Dokploy when omitted, but +// changing an explicitly configured value forces recreation. +func optionalComputedReplaceString(description string) schema.StringAttribute { + return schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + } +} + +func sensitiveString(description string, required bool) schema.StringAttribute { + return schema.StringAttribute{ + Required: required, + Optional: !required, + Computed: !required, + Sensitive: true, + MarkdownDescription: description, + } +} + +func enumString(description string, values []string, required bool) schema.StringAttribute { + return schema.StringAttribute{ + Required: required, + Optional: !required, + Computed: !required, + MarkdownDescription: description + " Valid values: `" + joinBackticked(values) + "`.", + Validators: []validator.String{stringvalidator.OneOf(values...)}, + } +} + +// enumStringWithDefault is an enum whose value Dokploy insists on receiving +// even though only one value is currently valid. +func enumStringWithDefault(description string, values []string, def string) schema.StringAttribute { + return schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: description + " Valid values: `" + joinBackticked(values) + "`. Defaults to `" + def + "`.", + Validators: []validator.String{stringvalidator.OneOf(values...)}, + Default: stringdefault.StaticString(def), + } +} + +// computedBool mirrors computedString for boolean server-assigned values. +func computedBool(description string) schema.BoolAttribute { + return schema.BoolAttribute{ + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + } +} + +func optionalComputedBool(description string) schema.BoolAttribute { + return schema.BoolAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + } +} + +// optionalBool has no server-side counterpart; it only steers provider +// behaviour. It is deliberately not Computed, so an unset value stays null and +// an imported resource shows no phantom diff. +func optionalBool(description string) schema.BoolAttribute { + return schema.BoolAttribute{Optional: true, MarkdownDescription: description} +} + +func optionalComputedInt(description string) schema.Int64Attribute { + return schema.Int64Attribute{ + Optional: true, + Computed: true, + MarkdownDescription: description, + PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}, + } +} + +func requiredInt(description string) schema.Int64Attribute { + return schema.Int64Attribute{Required: true, MarkdownDescription: description} +} + +func optionalComputedStringList(description string) schema.ListAttribute { + return schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + MarkdownDescription: description, + PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()}, + } +} + +// optionalJSON exposes one of Dokploy's free-form JSON columns (the Docker +// Swarm service settings) as a normalized JSON string, so semantically equal +// documents do not produce a diff. +func optionalJSON(description string) schema.StringAttribute { + return schema.StringAttribute{ + Optional: true, + CustomType: jsontypes.NormalizedType{}, + MarkdownDescription: description, + } +} + +func joinBackticked(values []string) string { + out := "" + for i, v := range values { + if i > 0 { + out += "`, `" + } + out += v + } + return out +} + +// Enum value sets mirrored from Dokploy's Postgres enums. +var ( + certificateTypes = []string{"letsencrypt", "none", "custom"} + sourceTypes = []string{"docker", "git", "github", "gitlab", "bitbucket", "gitea", "drop"} + buildTypes = []string{"dockerfile", "heroku_buildpacks", "paketo_buildpacks", "nixpacks", "static", "railpack"} + triggerTypes = []string{"push", "tag"} + composeTypes = []string{"docker-compose", "stack"} + composeSources = []string{"git", "github", "gitlab", "bitbucket", "gitea", "raw"} + domainTypes = []string{"compose", "application", "preview"} + mountTypes = []string{"bind", "volume", "file"} + serviceTypes = []string{"application", "postgres", "mysql", "mariadb", "mongo", "redis", "compose"} + protocolTypes = []string{"tcp", "udp"} + publishModes = []string{"ingress", "host"} +) diff --git a/internal/tfmap/tfmap.go b/internal/tfmap/tfmap.go new file mode 100644 index 0000000..c8ffd34 --- /dev/null +++ b/internal/tfmap/tfmap.go @@ -0,0 +1,555 @@ +// Package tfmap converts between Terraform Plugin Framework model structs and +// the flat JSON maps that the Dokploy API consumes and returns. +// +// Mapping is driven by a `dokploy:"..."` struct tag alongside the usual +// `tfsdk:"..."` tag: +// +// Name types.String `tfsdk:"name" dokploy:"name"` +// Description types.String `tfsdk:"description" dokploy:"description,nullable"` +// ID types.String `tfsdk:"id" dokploy:"projectId,id"` +// CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` +// +// Options: +// +// id this field carries the resource's primary key +// ro read-only; never included in a request body +// create included only in create bodies +// update included only in update bodies +// nullable send an explicit JSON null when the value is null, instead of +// omitting the key (only valid for API fields that accept null) +// +// Fields with no create/update option are sent in both. Null values are +// omitted by default, because Dokploy's Zod schemas reject null for columns +// that are NOT NULL, but accept a missing key as "leave unchanged". +package tfmap + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Phase selects which subset of fields to serialize. +type Phase int + +const ( + // PhaseCreate builds a body for a `*.create` procedure. + PhaseCreate Phase = iota + // PhaseUpdate builds a body for an `*.update` procedure. + PhaseUpdate +) + +type fieldSpec struct { + apiName string + index int + isID bool + readOnly bool + createOnly bool + updateOnly bool + nullable bool + noRead bool +} + +func specsFor(model any) ([]fieldSpec, error) { + v := reflect.ValueOf(model) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("tfmap: model must be a pointer to a struct, got %T", model) + } + t := v.Elem().Type() + + specs := make([]fieldSpec, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("dokploy") + if tag == "" || tag == "-" { + continue + } + parts := strings.Split(tag, ",") + spec := fieldSpec{apiName: parts[0], index: i} + for _, opt := range parts[1:] { + switch strings.TrimSpace(opt) { + case "id": + spec.isID = true + case "ro": + spec.readOnly = true + case "create": + spec.createOnly = true + case "update": + spec.updateOnly = true + case "nullable": + spec.nullable = true + case "noread": + spec.noRead = true + default: + return nil, fmt.Errorf("tfmap: unknown option %q on field %s", opt, t.Field(i).Name) + } + } + specs = append(specs, spec) + } + return specs, nil +} + +// ToAPI serializes a model into a Dokploy request body for the given phase. +// +// The primary-key field is included for updates (the API needs it to address +// the row) and omitted for creates (the server generates it). +func ToAPI(model any, phase Phase) (map[string]any, error) { + specs, err := specsFor(model) + if err != nil { + return nil, err + } + elem := reflect.ValueOf(model).Elem() + + body := map[string]any{} + for _, spec := range specs { + if spec.readOnly { + continue + } + if spec.isID { + if phase == PhaseUpdate { + if s, ok := elem.Field(spec.index).Interface().(types.String); ok && !s.IsNull() { + body[spec.apiName] = s.ValueString() + } + } + continue + } + if spec.createOnly && phase != PhaseCreate { + continue + } + if spec.updateOnly && phase != PhaseUpdate { + continue + } + + value, known, isNull, err := attrToAny(elem.Field(spec.index)) + if err != nil { + return nil, fmt.Errorf("field %q: %w", spec.apiName, err) + } + if !known { + // Unknown (computed at apply time) values must never be sent. + continue + } + if isNull { + if spec.nullable { + body[spec.apiName] = nil + } + continue + } + body[spec.apiName] = value + } + return body, nil +} + +// IDValue returns the value of the model's `id`-tagged field. +func IDValue(model any) (string, error) { + specs, err := specsFor(model) + if err != nil { + return "", err + } + elem := reflect.ValueOf(model).Elem() + for _, spec := range specs { + if !spec.isID { + continue + } + s, ok := elem.Field(spec.index).Interface().(types.String) + if !ok { + return "", fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName) + } + return s.ValueString(), nil + } + return "", fmt.Errorf("tfmap: model has no field tagged `id`") +} + +// SetID writes id into the model's `id`-tagged field. +func SetID(model any, id string) error { + specs, err := specsFor(model) + if err != nil { + return err + } + elem := reflect.ValueOf(model).Elem() + for _, spec := range specs { + if !spec.isID { + continue + } + if _, ok := elem.Field(spec.index).Interface().(types.String); !ok { + return fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName) + } + elem.Field(spec.index).Set(reflect.ValueOf(types.StringValue(id))) + return nil + } + return fmt.Errorf("tfmap: model has no field tagged `id`") +} + +// IDAPIName returns the API field name of the model's primary key, +// e.g. "projectId". +func IDAPIName(model any) (string, error) { + specs, err := specsFor(model) + if err != nil { + return "", err + } + for _, spec := range specs { + if spec.isID { + return spec.apiName, nil + } + } + return "", fmt.Errorf("tfmap: model has no field tagged `id`") +} + +// FromAPI populates a model from a Dokploy JSON response. +// +// Keys absent from the response leave the corresponding model field untouched, +// so partial responses do not clobber known state. +func FromAPI(raw json.RawMessage, model any) error { + var decoded map[string]json.RawMessage + if err := json.Unmarshal(raw, &decoded); err != nil { + return fmt.Errorf("tfmap: decoding response: %w", err) + } + + specs, err := specsFor(model) + if err != nil { + return err + } + elem := reflect.ValueOf(model).Elem() + + for _, spec := range specs { + if spec.noRead { + // The API returns a transformed value (a password hash, say) that + // must not overwrite what the practitioner configured. + continue + } + payload, present := decoded[spec.apiName] + if !present { + continue + } + if err := anyToAttr(payload, elem.Field(spec.index)); err != nil { + return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err) + } + } + return nil +} + +// NullifyUnknown replaces any attribute still marked unknown with a null of +// the same type. +// +// Terraform requires every value to be known once apply finishes. An attribute +// stays unknown when it was Optional+Computed in the plan and the API response +// simply omitted the corresponding key, which Dokploy does for several columns. +// Null is the honest representation: the server did not report a value. +func NullifyUnknown(model any) error { + specs, err := specsFor(model) + if err != nil { + return err + } + elem := reflect.ValueOf(model).Elem() + + for _, spec := range specs { + field := elem.Field(spec.index) + value, ok := field.Interface().(attr.Value) + if !ok || !value.IsUnknown() { + continue + } + if err := setNull(field); err != nil { + return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err) + } + } + return nil +} + +func setNull(field reflect.Value) error { + switch field.Interface().(type) { + case jsontypes.Normalized: + field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull())) + case types.String: + field.Set(reflect.ValueOf(types.StringNull())) + case types.Bool: + field.Set(reflect.ValueOf(types.BoolNull())) + case types.Int64: + field.Set(reflect.ValueOf(types.Int64Null())) + case types.Float64: + field.Set(reflect.ValueOf(types.Float64Null())) + case types.List: + field.Set(reflect.ValueOf(types.ListNull(types.StringType))) + case types.Set: + field.Set(reflect.ValueOf(types.SetNull(types.StringType))) + case types.Map: + field.Set(reflect.ValueOf(types.MapNull(types.StringType))) + default: + return fmt.Errorf("unsupported model field type %s", field.Type()) + } + return nil +} + +// attrToAny converts a framework value into a plain Go value suitable for JSON. +// It reports whether the value is known and whether it is null. +func attrToAny(field reflect.Value) (value any, known bool, isNull bool, err error) { + switch v := field.Interface().(type) { + case jsontypes.Normalized: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + // Re-marshal so the API receives a real JSON object, not a string. + var parsed any + if err := json.Unmarshal([]byte(v.ValueString()), &parsed); err != nil { + return nil, false, false, fmt.Errorf("value is not valid JSON: %w", err) + } + return parsed, true, false, nil + + case types.String: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + return v.ValueString(), true, false, nil + + case types.Bool: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + return v.ValueBool(), true, false, nil + + case types.Int64: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + return v.ValueInt64(), true, false, nil + + case types.Float64: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + return v.ValueFloat64(), true, false, nil + + case types.List: + return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements()) + + case types.Set: + return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements()) + + case types.Map: + if v.IsUnknown() { + return nil, false, false, nil + } + if v.IsNull() { + return nil, true, true, nil + } + out := map[string]any{} + for key, el := range v.Elements() { + s, ok := el.(types.String) + if !ok { + return nil, false, false, fmt.Errorf("map elements must be strings") + } + if s.IsNull() || s.IsUnknown() { + continue + } + out[key] = s.ValueString() + } + return out, true, false, nil + + default: + return nil, false, false, fmt.Errorf("unsupported model field type %T", v) + } +} + +func elementsToAny(unknown, null bool, elements []attr.Value) (any, bool, bool, error) { + if unknown { + return nil, false, false, nil + } + if null { + return nil, true, true, nil + } + out := make([]any, 0, len(elements)) + for _, el := range elements { + switch e := el.(type) { + case types.String: + if e.IsNull() || e.IsUnknown() { + continue + } + out = append(out, e.ValueString()) + case types.Int64: + if e.IsNull() || e.IsUnknown() { + continue + } + out = append(out, e.ValueInt64()) + default: + return nil, false, false, fmt.Errorf("unsupported collection element type %T", el) + } + } + return out, true, false, nil +} + +// anyToAttr decodes a raw JSON value into a framework model field. +func anyToAttr(payload json.RawMessage, field reflect.Value) error { + isNull := string(payload) == "null" + + switch field.Interface().(type) { + case jsontypes.Normalized: + if isNull { + field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull())) + return nil + } + field.Set(reflect.ValueOf(jsontypes.NewNormalizedValue(string(payload)))) + return nil + + case types.String: + if isNull { + field.Set(reflect.ValueOf(types.StringNull())) + return nil + } + var s string + if err := json.Unmarshal(payload, &s); err != nil { + // Tolerate scalars the API returns untyped (e.g. numeric strings). + var scalar any + if err2 := json.Unmarshal(payload, &scalar); err2 != nil { + return err + } + s = fmt.Sprintf("%v", scalar) + } + field.Set(reflect.ValueOf(types.StringValue(s))) + return nil + + case types.Bool: + if isNull { + field.Set(reflect.ValueOf(types.BoolNull())) + return nil + } + var b bool + if err := json.Unmarshal(payload, &b); err != nil { + return err + } + field.Set(reflect.ValueOf(types.BoolValue(b))) + return nil + + case types.Int64: + if isNull { + field.Set(reflect.ValueOf(types.Int64Null())) + return nil + } + var n json.Number + if err := json.Unmarshal(payload, &n); err != nil { + return err + } + i, err := n.Int64() + if err != nil { + f, ferr := n.Float64() + if ferr != nil { + return err + } + i = int64(f) + } + field.Set(reflect.ValueOf(types.Int64Value(i))) + return nil + + case types.Float64: + if isNull { + field.Set(reflect.ValueOf(types.Float64Null())) + return nil + } + var f float64 + if err := json.Unmarshal(payload, &f); err != nil { + return err + } + field.Set(reflect.ValueOf(types.Float64Value(f))) + return nil + + case types.List: + if isNull { + field.Set(reflect.ValueOf(types.ListNull(types.StringType))) + return nil + } + var items []any + if err := json.Unmarshal(payload, &items); err != nil { + return err + } + values := make([]attr.Value, 0, len(items)) + for _, item := range items { + values = append(values, types.StringValue(scalarToString(item))) + } + list, diags := types.ListValue(types.StringType, values) + if diags.HasError() { + return fmt.Errorf("building list: %v", diags.Errors()) + } + field.Set(reflect.ValueOf(list)) + return nil + + case types.Set: + if isNull { + field.Set(reflect.ValueOf(types.SetNull(types.StringType))) + return nil + } + var items []any + if err := json.Unmarshal(payload, &items); err != nil { + return err + } + values := make([]attr.Value, 0, len(items)) + for _, item := range items { + values = append(values, types.StringValue(scalarToString(item))) + } + set, diags := types.SetValue(types.StringType, values) + if diags.HasError() { + return fmt.Errorf("building set: %v", diags.Errors()) + } + field.Set(reflect.ValueOf(set)) + return nil + + case types.Map: + if isNull { + field.Set(reflect.ValueOf(types.MapNull(types.StringType))) + return nil + } + var items map[string]any + if err := json.Unmarshal(payload, &items); err != nil { + return err + } + values := map[string]attr.Value{} + for key, item := range items { + values[key] = types.StringValue(scalarToString(item)) + } + m, diags := types.MapValue(types.StringType, values) + if diags.HasError() { + return fmt.Errorf("building map: %v", diags.Errors()) + } + field.Set(reflect.ValueOf(m)) + return nil + + default: + return fmt.Errorf("unsupported model field type %s", field.Type()) + } +} + +func scalarToString(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + // JSON numbers decode as float64; render integers without a decimal. + if t == float64(int64(t)) { + return fmt.Sprintf("%d", int64(t)) + } + return fmt.Sprintf("%v", t) + case nil: + return "" + default: + return fmt.Sprintf("%v", t) + } +} + +// Ensure basetypes stays referenced for the type-switch cases above. +var _ = basetypes.NewStringNull diff --git a/internal/tfmap/tfmap_test.go b/internal/tfmap/tfmap_test.go new file mode 100644 index 0000000..b3d4895 --- /dev/null +++ b/internal/tfmap/tfmap_test.go @@ -0,0 +1,134 @@ +package tfmap + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type sample struct { + ID types.String `tfsdk:"id" dokploy:"thingId,id"` + Name types.String `tfsdk:"name" dokploy:"name"` + Desc types.String `tfsdk:"description" dokploy:"description,nullable"` + Networks types.List `tfsdk:"network_ids" dokploy:"networkIds"` + Detach types.Bool `tfsdk:"detach" dokploy:"detach"` + Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` + Secret types.String `tfsdk:"secret" dokploy:"secret,noread"` + ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"` + Status types.String `tfsdk:"status" dokploy:"status,ro"` +} + +// A JSON null in the response must produce a null attribute, never leave the +// attribute unknown: Terraform rejects unknown values after apply. +func TestFromAPIConvertsJSONNullToNullAttribute(t *testing.T) { + model := &sample{ + Networks: types.ListUnknown(types.StringType), + Detach: types.BoolUnknown(), + Replicas: types.Int64Unknown(), + } + + raw := json.RawMessage(`{"networkIds":null,"detach":null,"replicas":null}`) + if err := FromAPI(raw, model); err != nil { + t.Fatalf("FromAPI returned an error: %v", err) + } + + if model.Networks.IsUnknown() { + t.Error("networkIds stayed unknown; want null") + } + if !model.Networks.IsNull() { + t.Errorf("networkIds = %v; want null", model.Networks) + } + if model.Detach.IsUnknown() || !model.Detach.IsNull() { + t.Errorf("detach = %v; want null", model.Detach) + } + if model.Replicas.IsUnknown() || !model.Replicas.IsNull() { + t.Errorf("replicas = %v; want null", model.Replicas) + } +} + +// Keys absent from the response must not clobber existing values, but they +// must also not survive as unknown once NullifyUnknown runs. +func TestFromAPILeavesAbsentKeysUntouched(t *testing.T) { + model := &sample{ + Name: types.StringValue("keep-me"), + Networks: types.ListUnknown(types.StringType), + } + + if err := FromAPI(json.RawMessage(`{"description":"hi"}`), model); err != nil { + t.Fatalf("FromAPI returned an error: %v", err) + } + + if got := model.Name.ValueString(); got != "keep-me" { + t.Errorf("name = %q; want %q", got, "keep-me") + } + if got := model.Desc.ValueString(); got != "hi" { + t.Errorf("description = %q; want %q", got, "hi") + } + if !model.Networks.IsUnknown() { + t.Error("networkIds should still be unknown before NullifyUnknown runs") + } + + if err := NullifyUnknown(model); err != nil { + t.Fatalf("NullifyUnknown returned an error: %v", err) + } + if model.Networks.IsUnknown() { + t.Error("networkIds stayed unknown after NullifyUnknown") + } +} + +// noread fields must never be overwritten by the API response. +func TestFromAPISkipsNoReadFields(t *testing.T) { + model := &sample{Secret: types.StringValue("plaintext")} + + if err := FromAPI(json.RawMessage(`{"secret":"$2b$10$hashed"}`), model); err != nil { + t.Fatalf("FromAPI returned an error: %v", err) + } + if got := model.Secret.ValueString(); got != "plaintext" { + t.Errorf("secret = %q; want the configured value to survive", got) + } +} + +func TestToAPIPhaseSelection(t *testing.T) { + model := &sample{ + ID: types.StringValue("abc123"), + Name: types.StringValue("thing"), + Desc: types.StringNull(), + ServerID: types.StringValue("srv1"), + Status: types.StringValue("running"), + Networks: types.ListUnknown(types.StringType), + } + + create, err := ToAPI(model, PhaseCreate) + if err != nil { + t.Fatalf("ToAPI(create) returned an error: %v", err) + } + if _, ok := create["thingId"]; ok { + t.Error("create body must not carry the server-generated ID") + } + if create["serverId"] != "srv1" { + t.Errorf("create body serverId = %v; want srv1", create["serverId"]) + } + if _, ok := create["status"]; ok { + t.Error("read-only fields must never be sent") + } + if _, ok := create["networkIds"]; ok { + t.Error("unknown values must never be sent") + } + // description is null but tagged nullable, so it is sent explicitly. + value, ok := create["description"] + if !ok || value != nil { + t.Errorf("description = %v (present %v); want an explicit null", value, ok) + } + + update, err := ToAPI(model, PhaseUpdate) + if err != nil { + t.Fatalf("ToAPI(update) returned an error: %v", err) + } + if update["thingId"] != "abc123" { + t.Errorf("update body thingId = %v; want abc123", update["thingId"]) + } + if _, ok := update["serverId"]; ok { + t.Error("create-only fields must not be sent on update") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9dc9fb7 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +// terraform-provider-dokploy manages Dokploy projects, environments, services, +// databases and networking through Terraform or OpenTofu. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/provider" +) + +// version is overridden at build time with -ldflags "-X main.version=..." +var version = "dev" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "run the provider with support for debuggers such as delve") + flag.Parse() + + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + Address: "registry.terraform.io/maxvojtkov/dokploy", + Debug: debug, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/modules/web-service/main.tf b/modules/web-service/main.tf new file mode 100644 index 0000000..f3aa3be --- /dev/null +++ b/modules/web-service/main.tf @@ -0,0 +1,113 @@ +terraform { + required_version = ">= 1.5" + required_providers { + dokploy = { + source = "maxvojtkov/dokploy" + version = ">= 0.1" + } + } +} + +locals { + # Exactly one source block is populated, chosen by var.service_source.type. + is_docker = var.service_source.type == "docker" + is_github = var.service_source.type == "github" + is_git = var.service_source.type == "git" +} + +resource "dokploy_application" "this" { + name = var.name + description = var.description + environment_id = var.environment_id + server_id = var.server_id + + source_type = var.service_source.type + + # Docker image source + docker_image = local.is_docker ? var.service_source.image : null + username = local.is_docker ? var.service_source.registry_username : null + password = local.is_docker ? var.service_source.registry_password : null + registry_url = local.is_docker ? var.service_source.registry_url : null + + # GitHub source + github_id = local.is_github ? var.service_source.github_id : null + owner = local.is_github ? var.service_source.owner : null + repository = local.is_github ? var.service_source.repository : null + branch = local.is_github ? var.service_source.branch : null + build_path = local.is_github ? var.service_source.build_path : null + + # Plain Git source + custom_git_url = local.is_git ? var.service_source.url : null + custom_git_branch = local.is_git ? var.service_source.branch : null + custom_git_build_path = local.is_git ? var.service_source.build_path : null + custom_git_ssh_key_id = local.is_git ? var.service_source.ssh_key_id : null + + build_type = var.build_type + dockerfile = var.dockerfile + + env = var.env + build_args = var.build_args + auto_deploy = var.auto_deploy + + replicas = var.replicas + memory_reservation = var.resources.memory_reservation + memory_limit = var.resources.memory_limit + cpu_reservation = var.resources.cpu_reservation + cpu_limit = var.resources.cpu_limit +} + +resource "dokploy_domain" "this" { + for_each = { for domain in var.domains : domain.host => domain } + + application_id = dokploy_application.this.id + domain_type = "application" + host = each.value.host + path = each.value.path + port = each.value.port + https = each.value.https + certificate_type = each.value.https ? each.value.certificate_type : "none" + strip_path = each.value.strip_path + internal_path = each.value.internal_path +} + +resource "dokploy_port" "this" { + for_each = { for port in var.published_ports : "${port.protocol}-${port.published_port}" => port } + + application_id = dokploy_application.this.id + published_port = each.value.published_port + target_port = each.value.target_port + protocol = each.value.protocol + publish_mode = each.value.publish_mode +} + +resource "dokploy_mount" "this" { + for_each = { for mount in var.mounts : mount.mount_path => mount } + + service_id = dokploy_application.this.id + service_type = "application" + type = each.value.type + mount_path = each.value.mount_path + volume_name = each.value.volume_name + host_path = each.value.host_path + file_path = each.value.file_path + content = each.value.content +} + +# count rather than for_each: basic_auth is sensitive, and Terraform refuses to +# derive resource instance keys from sensitive values. +resource "dokploy_security" "this" { + count = var.basic_auth == null ? 0 : 1 + + application_id = dokploy_application.this.id + username = var.basic_auth.username + password = var.basic_auth.password +} + +resource "dokploy_redirect" "this" { + for_each = { for redirect in var.redirects : redirect.regex => redirect } + + application_id = dokploy_application.this.id + regex = each.value.regex + replacement = each.value.replacement + permanent = each.value.permanent +} diff --git a/modules/web-service/outputs.tf b/modules/web-service/outputs.tf new file mode 100644 index 0000000..93a060e --- /dev/null +++ b/modules/web-service/outputs.tf @@ -0,0 +1,29 @@ +output "id" { + description = "Application identifier." + value = dokploy_application.this.id +} + +output "app_name" { + description = "Docker service name Dokploy assigned to the application." + value = dokploy_application.this.app_name +} + +output "status" { + description = "Current application status reported by Dokploy." + value = dokploy_application.this.application_status +} + +output "domain_ids" { + description = "Domain identifiers keyed by hostname." + value = { for host, domain in dokploy_domain.this : host => domain.id } +} + +output "urls" { + description = "Public URLs the service is reachable on." + value = [for domain in var.domains : "${domain.https ? "https" : "http"}://${domain.host}${domain.path}"] +} + +output "mount_ids" { + description = "Mount identifiers keyed by container path." + value = { for path, mount in dokploy_mount.this : path => mount.id } +} diff --git a/modules/web-service/variables.tf b/modules/web-service/variables.tf new file mode 100644 index 0000000..aad6a60 --- /dev/null +++ b/modules/web-service/variables.tf @@ -0,0 +1,186 @@ +variable "name" { + description = "Display name of the application." + type = string +} + +variable "description" { + description = "Free-form description shown in the Dokploy UI." + type = string + default = null +} + +variable "environment_id" { + description = "Environment the service belongs to. Usually dokploy_project..default_environment_id." + type = string +} + +variable "server_id" { + description = "Remote server to deploy on. Leave null to use the Dokploy host itself." + type = string + default = null +} + +variable "service_source" { + description = <<-DESC + Where the service comes from. Set `type` to one of `docker`, `github` or `git` + and populate the matching fields; the rest are ignored. + DESC + type = object({ + type = string + + # type = "docker" + image = optional(string) + registry_username = optional(string) + registry_password = optional(string) + registry_url = optional(string) + + # type = "github" + github_id = optional(string) + owner = optional(string) + repository = optional(string) + + # type = "git" + url = optional(string) + ssh_key_id = optional(string) + + # shared by the git-backed types + branch = optional(string) + build_path = optional(string) + }) + + validation { + condition = contains(["docker", "github", "git"], var.service_source.type) + error_message = "service_source.type must be one of: docker, github, git." + } + + validation { + condition = var.service_source.type != "docker" || var.service_source.image != null + error_message = "service_source.image is required when source.type is \"docker\"." + } + + validation { + condition = var.service_source.type != "github" || ( + var.service_source.owner != null && var.service_source.repository != null && var.service_source.branch != null + ) + error_message = "service_source.owner, source.repository and source.branch are required when source.type is \"github\"." + } + + validation { + condition = var.service_source.type != "git" || (var.service_source.url != null && var.service_source.branch != null) + error_message = "service_source.url and source.branch are required when source.type is \"git\"." + } +} + +variable "build_type" { + description = "How the application is built. Ignored when the source is a prebuilt Docker image." + type = string + default = null + + validation { + condition = var.build_type == null || contains( + ["dockerfile", "heroku_buildpacks", "paketo_buildpacks", "nixpacks", "static", "railpack"], + coalesce(var.build_type, "nixpacks") + ) + error_message = "build_type must be one of: dockerfile, heroku_buildpacks, paketo_buildpacks, nixpacks, static, railpack." + } +} + +variable "dockerfile" { + description = "Path to the Dockerfile, when build_type is \"dockerfile\"." + type = string + default = null +} + +variable "env" { + description = "Runtime environment variables in KEY=value form, one per line." + type = string + default = null + sensitive = true +} + +variable "build_args" { + description = "Docker build arguments in KEY=value form, one per line." + type = string + default = null +} + +variable "auto_deploy" { + description = "Redeploy automatically when the configured Git trigger fires." + type = bool + default = null +} + +variable "replicas" { + description = "Number of replicas to run." + type = number + default = null +} + +variable "resources" { + description = "Container resource reservations and limits, as Docker-style strings such as \"512m\" or \"1.5\"." + type = object({ + memory_reservation = optional(string) + memory_limit = optional(string) + cpu_reservation = optional(string) + cpu_limit = optional(string) + }) + default = {} +} + +variable "domains" { + description = "Domains routed to this service through Traefik." + type = list(object({ + host = string + port = optional(number, 3000) + path = optional(string, "/") + https = optional(bool, true) + certificate_type = optional(string, "letsencrypt") + strip_path = optional(bool, false) + internal_path = optional(string, "/") + })) + default = [] +} + +variable "published_ports" { + description = "Ports published directly on the host, bypassing Traefik." + type = list(object({ + published_port = number + target_port = number + protocol = optional(string, "tcp") + publish_mode = optional(string, "host") + })) + default = [] +} + +variable "mounts" { + description = "Volumes, bind mounts and config files attached to the service." + type = list(object({ + mount_path = string + type = optional(string, "volume") + volume_name = optional(string) + host_path = optional(string) + file_path = optional(string) + content = optional(string) + })) + default = [] +} + +variable "basic_auth" { + description = "HTTP basic auth credentials protecting the service's domains. Null disables it." + type = object({ + username = string + password = string + }) + default = null + sensitive = true +} + +variable "redirects" { + description = "Traefik redirect rules attached to the service." + type = list(object({ + regex = string + replacement = string + permanent = optional(bool, true) + })) + default = [] +} diff --git a/shim/shim.go b/shim/shim.go new file mode 100644 index 0000000..353079a --- /dev/null +++ b/shim/shim.go @@ -0,0 +1,16 @@ +// Package shim exposes the Dokploy Terraform provider to external Go modules. +// +// internal/provider cannot be imported outside this module, so downstream +// consumers -- notably the Pulumi bridge in pulumi-dokploy -- go through here. +package shim + +import ( + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" + + "github.com/maxvojtkov/terraform-provider-dokploy/internal/provider" +) + +// NewProvider returns the plugin-framework provider stamped with version. +func NewProvider(version string) fwprovider.Provider { + return provider.New(version)() +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..70bad5d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "skills": { + "new-terraform-provider": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/new-terraform-provider/SKILL.md", + "computedHash": "ad0de484d8c1dacc628d64e1368e026bdeed54ae676694126706a4879a9fb41d" + }, + "provider-actions": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/provider-actions/SKILL.md", + "computedHash": "bfa4d8e3b12721da037fe3252853288c435d2ec7d1952f1f544bce0330aaefba" + }, + "provider-docs": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/provider-docs/SKILL.md", + "computedHash": "98f26d2e497e94913fe00b9544c6b5cb81325ead615aaf3ddf87d272ff882e43" + }, + "provider-resources": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/provider-resources/SKILL.md", + "computedHash": "97edcbb115a59d93da5ec28cb445d883307c66143b6fbc1f6f61bd4b2cfd7def" + }, + "provider-test-patterns": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/provider-test-patterns/SKILL.md", + "computedHash": "703ec35664c962545a33c1f50d489be90e8656b33ce488cc9357c154e4dc5494" + }, + "pulumi-terraform-to-pulumi": { + "source": "pulumi/agent-skills", + "sourceType": "github", + "skillPath": "migration/skills/pulumi-terraform-to-pulumi/SKILL.md", + "computedHash": "0a42023008823ae9e717e1d45a94d94065a7dc3d56e398f5115a901ba2e98324" + }, + "push-to-registry": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "packer/hcp/skills/push-to-registry/SKILL.md", + "computedHash": "11eaa6387c0066d992096f630221c5f51d3cfdcd045797658b494abe60eac9ce" + }, + "refactor-module": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/module-generation/skills/refactor-module/SKILL.md", + "computedHash": "69059424621c33cacf429c19ec6f8be7ed8478f0531638e5f756d33bfa99dfcb" + }, + "run-acceptance-tests": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/provider-development/skills/run-acceptance-tests/SKILL.md", + "computedHash": "cecbb189ebe62eb64c5d2560966ca3fef6545effd9105fb8117145630eac08a9" + }, + "terraform-policy": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/policy/skills/terraform-policy/SKILL.md", + "computedHash": "0114a78007e038eabb57114977e7e403008a2b943e58724b16baeec6eb65babc" + }, + "terraform-search-import": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/code-generation/skills/terraform-search-import/SKILL.md", + "computedHash": "2ea8a5558abf69b9433bc76567686c8b0cdf1a5a4e4cc6919bc1a0700959bae9" + }, + "terraform-stacks": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/module-generation/skills/terraform-stacks/SKILL.md", + "computedHash": "fe2654a77b1f7eefa29a7dc161d2c703675439cec6194f1aedba414401694b59" + }, + "terraform-style-guide": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/code-generation/skills/terraform-style-guide/SKILL.md", + "computedHash": "e7776668ff288f856a8f9bb8bb3301dd2c78c70102d81fb1027c059b01f6b07b" + }, + "terraform-test": { + "source": "hashicorp/agent-skills", + "sourceType": "github", + "skillPath": "terraform/code-generation/skills/terraform-test/SKILL.md", + "computedHash": "c446a090ac98bea46704c16003761a030e6aa56a27b0e95d6914fbdb7c1d2a23" + } + } +}