diff --git a/.github/workflows/tofu-test.yml b/.github/workflows/tofu-test.yml index 2a390226..f810ac2c 100644 --- a/.github/workflows/tofu-test.yml +++ b/.github/workflows/tofu-test.yml @@ -30,7 +30,7 @@ jobs: test-nullplatform-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main with: - modules: '["nullplatform/account", "nullplatform/agent", "nullplatform/api_key", "nullplatform/dimension", "nullplatform/dimension_value", "nullplatform/users", "nullplatform/metrics", "nullplatform/asset/docker_server", "nullplatform/cloud/azure/cloud", "nullplatform/cloud/gcp/cloud", "nullplatform/cloud/aws/cloud", "nullplatform/code_repository", "nullplatform/base", "nullplatform/agent"]' + modules: '["nullplatform/account", "nullplatform/agent", "nullplatform/api_key", "nullplatform/dimension", "nullplatform/dimension_value", "nullplatform/users", "nullplatform/metrics", "nullplatform/asset/docker_server", "nullplatform/cloud/azure/cloud", "nullplatform/cloud/gcp/cloud", "nullplatform/cloud/aws/cloud", "nullplatform/code_repository", "nullplatform/base", "nullplatform/agent", "nullplatform/scope_configuration", "nullplatform/parameter_storage_configuration"]' test-container-orchestration-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main diff --git a/nullplatform/parameter_storage_configuration/README.md b/nullplatform/parameter_storage_configuration/README.md index 9f5f6f86..4079af2c 100644 --- a/nullplatform/parameter_storage_configuration/README.md +++ b/nullplatform/parameter_storage_configuration/README.md @@ -42,12 +42,12 @@ resource "example_resource" "this" { `type` selects the provider specification this configuration targets. Each type has its own payload and its own set of type-specific variables; adding a new type means adding it to the list below along with its variables. -### aws-secrets-manager (default) +### aws-secrets-manager -| Variable | Maps to | -|----------|---------| -| `applies_to` | `sensibility.applies_to` | -| `kms_key_id` | `setup.kms_key_id` | +| Variable | Maps to | Default | +|----------|---------|---------| +| `applies_to` | `sensibility.applies_to` | `["secret"]` | +| `kms_key_id` | `setup.kms_key_id` | `""` (aws/secretsmanager managed key) | ```hcl module "parameter_storage_configuration" { @@ -62,6 +62,30 @@ module "parameter_storage_configuration" { } ``` +### aws-parameter-store + +Stores parameters in AWS SSM Parameter Store (spec declared by +`nullplatform/parameters-provider`, `aws-parameter-store-configuration.json.tpl`). + +| Variable | Maps to | Default | +|----------|---------|---------| +| `applies_to` | `sensibility.applies_to` | `["non_secret"]` | +| `kms_key_id` | `setup.kms_key_id` | `""` (alias/aws/ssm managed key) | +| `tier` | `setup.tier` | `"Standard"` (also `Advanced`, `Intelligent-Tiering`) | + +```hcl +module "parameter_storage_configuration" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/parameter_storage_configuration?ref=vX.Y.Z" + + nrn = "your-nrn" + type = "aws-parameter-store" + + applies_to = ["non_secret"] + tier = "Standard" + dimensions = { environment = "development" } +} +``` + ## Requirements diff --git a/nullplatform/parameter_storage_configuration/locals.tf b/nullplatform/parameter_storage_configuration/locals.tf index 56e1dd5a..29592d67 100644 --- a/nullplatform/parameter_storage_configuration/locals.tf +++ b/nullplatform/parameter_storage_configuration/locals.tf @@ -10,6 +10,15 @@ locals { kms_key_id = "" } } + "aws-parameter-store" = { + sensibility = { + applies_to = ["non_secret"] + } + setup = { + kms_key_id = "" + tier = "Standard" + } + } } # Per-type override, merged on top of type_defaults. Each entry indexes @@ -17,8 +26,19 @@ locals { # this map is evaluated in full regardless of the selected type. type_overrides = { "aws-secrets-manager" = { - sensibility = merge(local.type_defaults["aws-secrets-manager"].sensibility, { applies_to = var.applies_to }) - setup = merge(local.type_defaults["aws-secrets-manager"].setup, { kms_key_id = var.kms_key_id }) + sensibility = merge(local.type_defaults["aws-secrets-manager"].sensibility, { + applies_to = coalesce(var.applies_to, local.type_defaults["aws-secrets-manager"].sensibility.applies_to) + }) + setup = merge(local.type_defaults["aws-secrets-manager"].setup, { kms_key_id = var.kms_key_id }) + } + "aws-parameter-store" = { + sensibility = merge(local.type_defaults["aws-parameter-store"].sensibility, { + applies_to = coalesce(var.applies_to, local.type_defaults["aws-parameter-store"].sensibility.applies_to) + }) + setup = merge(local.type_defaults["aws-parameter-store"].setup, { + kms_key_id = var.kms_key_id + tier = coalesce(var.tier, local.type_defaults["aws-parameter-store"].setup.tier) + }) } } diff --git a/nullplatform/parameter_storage_configuration/tests/parameter_storage_configuration.tftest.hcl b/nullplatform/parameter_storage_configuration/tests/parameter_storage_configuration.tftest.hcl new file mode 100644 index 00000000..73c8ed9f --- /dev/null +++ b/nullplatform/parameter_storage_configuration/tests/parameter_storage_configuration.tftest.hcl @@ -0,0 +1,103 @@ +mock_provider "nullplatform" {} + +variables { + nrn = "organization=1:account=2" +} + +run "secrets_manager_defaults" { + command = plan + + variables { + type = "aws-secrets-manager" + } + + assert { + condition = nullplatform_provider_config.parameter_store_configuration.type == "aws-secrets-manager" + error_message = "type should be aws-secrets-manager" + } + + assert { + condition = jsonencode(jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).sensibility.applies_to) == jsonencode(["secret"]) + error_message = "aws-secrets-manager should default applies_to to [secret]" + } + + assert { + condition = !can(jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).setup.tier) + error_message = "tier must not be sent for aws-secrets-manager" + } +} + +run "parameter_store_defaults" { + command = plan + + variables { + type = "aws-parameter-store" + } + + assert { + condition = nullplatform_provider_config.parameter_store_configuration.type == "aws-parameter-store" + error_message = "type should be aws-parameter-store" + } + + assert { + condition = jsonencode(jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).sensibility.applies_to) == jsonencode(["non_secret"]) + error_message = "aws-parameter-store should default applies_to to [non_secret]" + } + + assert { + condition = jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).setup.tier == "Standard" + error_message = "tier should default to Standard" + } + + assert { + condition = jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).setup.kms_key_id == "" + error_message = "kms_key_id should default to empty" + } +} + +run "parameter_store_overrides" { + command = plan + + variables { + type = "aws-parameter-store" + tier = "Advanced" + kms_key_id = "alias/my-key" + applies_to = ["secret", "non_secret"] + } + + assert { + condition = jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).setup.tier == "Advanced" + error_message = "tier override should be applied" + } + + assert { + condition = jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).setup.kms_key_id == "alias/my-key" + error_message = "kms_key_id override should be applied" + } + + assert { + condition = length(jsondecode(nullplatform_provider_config.parameter_store_configuration.attributes).sensibility.applies_to) == 2 + error_message = "applies_to override should be applied" + } +} + +run "tier_rejected_for_secrets_manager" { + command = plan + + variables { + type = "aws-secrets-manager" + tier = "Standard" + } + + expect_failures = [var.tier] +} + +run "unknown_type_rejected" { + command = plan + + variables { + type = "hashicorp-vault" + } + + expect_failures = [var.type] +} diff --git a/nullplatform/parameter_storage_configuration/variables.tf b/nullplatform/parameter_storage_configuration/variables.tf index c38bc952..ca91b0c5 100644 --- a/nullplatform/parameter_storage_configuration/variables.tf +++ b/nullplatform/parameter_storage_configuration/variables.tf @@ -8,30 +8,48 @@ variable "type" { type = string validation { - condition = contains(["aws-secrets-manager"], var.type) - error_message = "type must be one of: aws-secrets-manager." + condition = contains(["aws-secrets-manager", "aws-parameter-store"], var.type) + error_message = "type must be one of: aws-secrets-manager, aws-parameter-store." } } variable "kms_key_id" { - description = "aws-secrets-manager only. Customer-managed KMS key ARN or alias. If empty, the default aws/secretsmanager managed key is used." + description = "Customer-managed KMS key ARN or alias. If empty, the service's AWS-managed key is used (aws/secretsmanager for aws-secrets-manager, alias/aws/ssm for aws-parameter-store)." type = string default = "" +} + +variable "applies_to" { + description = "Which parameters this backend stores: any of secret, non_secret. Defaults to the spec's own default for the type — [\"secret\"] for aws-secrets-manager, [\"non_secret\"] for aws-parameter-store." + type = list(string) + default = null + + validation { + condition = var.applies_to == null || alltrue([ + for v in coalesce(var.applies_to, []) : contains(["secret", "non_secret"], v) + ]) + error_message = "applies_to entries must be one of: secret, non_secret." + } validation { - condition = var.type == "aws-secrets-manager" || var.kms_key_id == "" - error_message = "kms_key_id only applies when type is 'aws-secrets-manager'." + condition = var.applies_to == null || length(var.applies_to) > 0 + error_message = "applies_to must list at least one parameter kind when set." } } -variable "applies_to" { - description = "aws-secrets-manager only. Resource types this parameter storage configuration applies to." - type = list(string) - default = ["secret"] +variable "tier" { + description = "aws-parameter-store only. SSM parameter tier: Standard (free up to 10,000 parameters), Advanced (larger values, billed per parameter) or Intelligent-Tiering. Defaults to Standard." + type = string + default = null + + validation { + condition = var.tier == null || contains(["Standard", "Advanced", "Intelligent-Tiering"], var.tier) + error_message = "tier must be one of: Standard, Advanced, Intelligent-Tiering." + } validation { - condition = var.type == "aws-secrets-manager" || var.applies_to == ["secret"] - error_message = "applies_to only applies when type is 'aws-secrets-manager'." + condition = var.type == "aws-parameter-store" || var.tier == null + error_message = "tier only applies when type is 'aws-parameter-store'." } } diff --git a/nullplatform/scope_configuration/README.md b/nullplatform/scope_configuration/README.md index ce51a854..d286184b 100644 --- a/nullplatform/scope_configuration/README.md +++ b/nullplatform/scope_configuration/README.md @@ -11,7 +11,7 @@ The module creates a single nullplatform_provider_config resource that encodes p ## Features - Creates a nullplatform_provider_config resource with type-dispatched JSON attributes for static-files or aws-lambda provider specs -- Configures AWS CloudFront distribution settings with optional WAF WebACL attachment for static file delivery +- Configures AWS CloudFront distribution settings with optional WAF WebACL attachment and Lambda@Edge function associations for static file delivery - Configures Route53 DNS network settings including public hosted zone binding for static-files deployments - Configures OpenTofu remote state bucket and placeholder ECR image URI for aws-lambda scope deployments - Optionally attaches a nullplatform agent Lambda layer ARN when USE_NULL_AGENT is enabled on the scope @@ -48,6 +48,16 @@ module "scope_configuration" { } ``` +Lambda@Edge functions on the CloudFront default cache behavior are declared with +`aws_lambda_associations`, one entry per CloudFront event. The key is only sent +when the list is non-empty, so configurations without associations do not drift: + +```hcl + aws_lambda_associations = [ + { event_type = "viewer-response", function_arn = "arn:aws:lambda:us-east-1:123456789012:function:edge-headers:1" }, + ] +``` + ### Usage with AWS Lambda ```hcl diff --git a/nullplatform/scope_configuration/locals.tf b/nullplatform/scope_configuration/locals.tf index 4db2bb41..99d24532 100644 --- a/nullplatform/scope_configuration/locals.tf +++ b/nullplatform/scope_configuration/locals.tf @@ -30,7 +30,13 @@ locals { aws_region = var.aws_region aws_state_bucket = var.aws_state_bucket } - distribution = merge(local.static_files_defaults.distribution, { aws_distribution = var.aws_distribution }) + # lambda_associations has no default in the spec: only sent when the + # caller declares some, so a config without them never drifts. + distribution = merge( + local.static_files_defaults.distribution, + { aws_distribution = var.aws_distribution }, + length(var.aws_lambda_associations) > 0 ? { lambda_associations = var.aws_lambda_associations } : {}, + ) network = merge(local.static_files_defaults.network, { aws_network = var.aws_network aws_hosted_public_zone_id = var.aws_hosted_public_zone_id diff --git a/nullplatform/scope_configuration/tests/scope_configuration.tftest.hcl b/nullplatform/scope_configuration/tests/scope_configuration.tftest.hcl new file mode 100644 index 00000000..3b8596c5 --- /dev/null +++ b/nullplatform/scope_configuration/tests/scope_configuration.tftest.hcl @@ -0,0 +1,99 @@ +mock_provider "nullplatform" {} + +variables { + nrn = "organization=1:account=2" +} + +run "static_files_payload" { + command = plan + + variables { + type = "static-files" + cloud_provider = "aws" + aws_region = "us-east-1" + aws_state_bucket = "tf-state" + aws_hosted_public_zone_id = "Z0000000000000" + } + + assert { + condition = nullplatform_provider_config.scope_configuration.type == "static-files" + error_message = "type should be static-files" + } + + assert { + condition = jsondecode(nullplatform_provider_config.scope_configuration.attributes).distribution.aws_distribution == "cloudfront" + error_message = "distribution should default to cloudfront" + } + + assert { + condition = !can(jsondecode(nullplatform_provider_config.scope_configuration.attributes).distribution.lambda_associations) + error_message = "lambda_associations must be omitted when none are declared" + } +} + +run "static_files_lambda_associations" { + command = plan + + variables { + type = "static-files" + cloud_provider = "aws" + aws_region = "us-east-1" + aws_state_bucket = "tf-state" + aws_hosted_public_zone_id = "Z0000000000000" + aws_lambda_associations = [ + { event_type = "viewer-response", function_arn = "arn:aws:lambda:us-east-1:123456789012:function:edge-headers:1" } + ] + } + + assert { + condition = length(jsondecode(nullplatform_provider_config.scope_configuration.attributes).distribution.lambda_associations) == 1 + error_message = "one lambda association should be sent" + } + + assert { + condition = jsondecode(nullplatform_provider_config.scope_configuration.attributes).distribution.lambda_associations[0].event_type == "viewer-response" + error_message = "event_type should be passed through" + } + + assert { + condition = jsondecode(nullplatform_provider_config.scope_configuration.attributes).distribution.aws_distribution == "cloudfront" + error_message = "adding associations must keep the distribution defaults" + } +} + +run "static_files_rejects_unknown_event" { + command = plan + + variables { + type = "static-files" + cloud_provider = "aws" + aws_region = "us-east-1" + aws_state_bucket = "tf-state" + aws_hosted_public_zone_id = "Z0000000000000" + aws_lambda_associations = [ + { event_type = "on-click", function_arn = "arn:aws:lambda:us-east-1:123456789012:function:f:1" } + ] + } + + expect_failures = [var.aws_lambda_associations] +} + +run "aws_lambda_payload" { + command = plan + + variables { + type = "aws-lambda" + lambda_tofu_state_bucket = "lambda-state" + lambda_placeholder_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/placeholder:latest" + } + + assert { + condition = jsondecode(nullplatform_provider_config.scope_configuration.attributes).state.tofu_state_bucket == "lambda-state" + error_message = "state bucket should be passed through" + } + + assert { + condition = !can(jsondecode(nullplatform_provider_config.scope_configuration.attributes).agent) + error_message = "agent block must be omitted without a layer ARN" + } +} diff --git a/nullplatform/scope_configuration/variables.tf b/nullplatform/scope_configuration/variables.tf index 783b6038..ca1c7aa0 100644 --- a/nullplatform/scope_configuration/variables.tf +++ b/nullplatform/scope_configuration/variables.tf @@ -151,6 +151,33 @@ variable "aws_web_acl_name" { } } +variable "aws_lambda_associations" { + description = "Lambda@Edge functions attached to the CloudFront default cache behavior, one entry per CloudFront event. function_arn must include a published version. Empty (the default) leaves distribution.lambda_associations out of the payload, matching a spec that never declared it." + type = list(object({ + event_type = string + function_arn = string + })) + default = [] + + validation { + condition = alltrue([ + for a in var.aws_lambda_associations : + contains(["viewer-request", "viewer-response", "origin-request", "origin-response"], a.event_type) + ]) + error_message = "aws_lambda_associations[*].event_type must be one of: viewer-request, viewer-response, origin-request, origin-response." + } + + validation { + condition = length(distinct([for a in var.aws_lambda_associations : a.event_type])) == length(var.aws_lambda_associations) + error_message = "aws_lambda_associations must not repeat an event_type: CloudFront accepts one function per event on the default cache behavior." + } + + validation { + condition = var.cloud_provider == "aws" || length(var.aws_lambda_associations) == 0 + error_message = "aws_lambda_associations only applies when cloud_provider is 'aws'." + } +} + ################################################################################ # aws-lambda ################################################################################