diff --git a/README.md b/README.md index b889075..1649f99 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ If you have many remote repositories that you need to manage via this pattern, y | [spacelift_stack.default](https://registry.terraform.io/providers/spacelift-io/spacelift/latest/docs/resources/stack) | resource | | [spacelift_stack_destructor.default](https://registry.terraform.io/providers/spacelift-io/spacelift/latest/docs/resources/stack_destructor) | resource | | [jsonschema_validator.runtime_overrides](https://registry.terraform.io/providers/bpedman/jsonschema/latest/docs/data-sources/validator) | data source | +| [spacelift_aws_integrations.all](https://registry.terraform.io/providers/spacelift-io/spacelift/latest/docs/data-sources/aws_integrations) | data source | | [spacelift_spaces.all](https://registry.terraform.io/providers/spacelift-io/spacelift/latest/docs/data-sources/spaces) | data source | | [spacelift_worker_pools.all](https://registry.terraform.io/providers/spacelift-io/spacelift/latest/docs/data-sources/worker_pools) | data source | @@ -347,6 +348,7 @@ If you have many remote repositories that you need to manage via this pattern, y | [aws\_integration\_attachment\_write](#input\_aws\_integration\_attachment\_write) | Indicates whether this attachment is used for write operations. | `bool` | `true` | no | | [aws\_integration\_enabled](#input\_aws\_integration\_enabled) | Indicates whether the AWS integration is enabled. | `bool` | `false` | no | | [aws\_integration\_id](#input\_aws\_integration\_id) | ID of the AWS integration to attach. | `string` | `null` | no | +| [aws\_integration\_name](#input\_aws\_integration\_name) | Name of the AWS integration to attach, which will be resolved to aws\_integration\_id. We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues. | `string` | `null` | no | | [azure\_devops](#input\_azure\_devops) | The Azure DevOps integration settings |
object({
project = string
id = optional(string)
})
| `null` | no | | [before\_apply](#input\_before\_apply) | List of before-apply scripts | `list(string)` | `[]` | no | | [before\_destroy](#input\_before\_destroy) | List of before-destroy scripts | `list(string)` | `[]` | no | @@ -379,12 +381,12 @@ If you have many remote repositories that you need to manage via this pattern, y | [runner\_image](#input\_runner\_image) | URL of the Docker image used to process Runs. Defaults to `null` which is Spacelift's standard (Alpine) runner image. | `string` | `null` | no | | [runtime\_overrides](#input\_runtime\_overrides) | Runtime overrides that are merged into the stack config.
This allows for per-root-module overrides of the stack resources at runtime
so you have more flexibility beyond the variable defaults and the static stack config files.
Keys are the root module names and values match the StackConfig schema.
See `stack-config.schema.json` for full details on the schema and
`tests/fixtures/multi-instance/root-module-a/stacks/default-example.yaml` for a complete example. | `any` | `{}` | no | | [space\_id](#input\_space\_id) | Place the created stacks in the specified space\_id. Mutually exclusive with space\_name. | `string` | `null` | no | -| [space\_name](#input\_space\_name) | Place the created stacks in the specified space\_name. Mutually exclusive with space\_id. | `string` | `null` | no | +| [space\_name](#input\_space\_name) | Place the created stacks in the specified space\_name. Mutually exclusive with space\_id. We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues. | `string` | `null` | no | | [spaces](#input\_spaces) | A map of Spacelift Spaces to create |
map(object({
description = optional(string, null)
inherit_entities = optional(bool, false)
labels = optional(list(string), null)
parent_space_id = optional(string, "root")
}))
| `{}` | no | | [terraform\_smart\_sanitization](#input\_terraform\_smart\_sanitization) | Indicates whether runs on this will use terraform's sensitive value system to sanitize
the outputs of Terraform state and plans in spacelift instead of sanitizing all fields. | `bool` | `false` | no | | [terraform\_version](#input\_terraform\_version) | OpenTofu/Terraform version to use. Defaults to the latest available version of the `terraform_workflow_tool`. | `string` | `null` | no | | [terraform\_workflow\_tool](#input\_terraform\_workflow\_tool) | Defines the tool that will be used to execute the workflow.
This can be one of OPEN\_TOFU, TERRAFORM\_FOSS or CUSTOM. | `string` | `"OPEN_TOFU"` | no | -| [worker\_pool\_id](#input\_worker\_pool\_id) | ID of the worker pool to use. Mutually exclusive with worker\_pool\_name.
NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift. | `string` | `null` | no | +| [worker\_pool\_id](#input\_worker\_pool\_id) | ID of the worker pool to use. Mutually exclusive with worker\_pool\_name.
NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift.
We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues. | `string` | `null` | no | | [worker\_pool\_name](#input\_worker\_pool\_name) | Name of the worker pool to use. Mutually exclusive with worker\_pool\_id.
NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift. | `string` | `null` | no | ## Outputs diff --git a/checks.tf b/checks.tf new file mode 100644 index 0000000..d7ee886 --- /dev/null +++ b/checks.tf @@ -0,0 +1,42 @@ +/* + * These check blocks assertions enforce mutual exclusivity between ID and name variables. + * + * It took a bit for me to understand the logic, so here's an explanation (with the help with AI): + * The condition "var.x_id == null || var.x_name == null" is an ASSERTION that must be TRUE. + * It asserts: "space_id must be null OR space_name must be null" (at least one must be null). + * + * When both are set: space_id is NOT null AND space_name is NOT null + * → false || false = false → ASSERTION FAILS → TF fails (so BOTH shouldn't be set at the same time) + * + * When only one is set: one is null, one is not null + * → true || false = true → ASSERTION PASSES → TF continues (so one can be set, the other can be null) + * + * Truth Table: + * | x_id | x_name | Condition Result | TF Action | + * |-----------|-------------|-------------------------|------------------| + * | null | null | true || true = true | ✅ PASS | + * | null | "some-name" | true || false = true | ✅ PASS | + * | "some-id" | null | false || true = true | ✅ PASS | + * | "some-id" | "some-name" | false || false = false | ❌ FAIL | + */ + +check "spaces_enforce_exclusivity" { + assert { + condition = var.space_id == null || var.space_name == null + error_message = "space_id and space_name are mutually exclusive." + } +} + +check "worker_pools_mutual_exclusivity" { + assert { + condition = var.worker_pool_id == null || var.worker_pool_name == null + error_message = "worker_pool_id and worker_pool_name are mutually exclusive." + } +} + +check "aws_integrations_mutual_exclusivity" { + assert { + condition = var.aws_integration_id == null || var.aws_integration_name == null + error_message = "aws_integration_id and aws_integration_name are mutually exclusive." + } +} diff --git a/data.tf b/data.tf index 95c1ba0..6292bf3 100644 --- a/data.tf +++ b/data.tf @@ -1,8 +1,7 @@ -# Look up all spaces in order to map space names to space IDs +# Look up data sources in order to map [NAME] to [ID] data "spacelift_spaces" "all" {} - -# Look up all worker pools in order to map worker pool names to IDs data "spacelift_worker_pools" "all" {} +data "spacelift_aws_integrations" "all" {} # Validate the runtime overrides against the schema # Frustrating that we have to do this, but this successfully validates the typing diff --git a/main.tf b/main.tf index f3a4020..c6c9573 100644 --- a/main.tf +++ b/main.tf @@ -288,22 +288,6 @@ locals { )) } - ## Handle space lookups - - # Allow usage of space_name along with space_id. - # A space_id is long and hard to look at in the stack.yaml file, so pass in the space_name and it will be resolved to the space_id, which will be consumed by the `spacelife_stack` resource. - space_name_to_id = { - for space in data.spacelift_spaces.all.spaces : - space.name => space.space_id - } - - ## Handle worker pool names - # Allow usage of worker_pool_name along with worker_pool_id. - worker_pool_name_to_id = { - for pool in data.spacelift_worker_pools.all.worker_pools : - pool.name => pool.worker_pool_id - } - # Helper for property resolution with fallback to defaults stack_property_resolver = { for stack in local.stacks : stack => { @@ -325,7 +309,7 @@ locals { worker_pool_id = try(local.stack_configs[stack].worker_pool_id, var.worker_pool_id) # AWS Integration properties - aws_integration_id = try(local.stack_configs[stack].aws_integration_id, var.aws_integration_id) + aws_integration_id = local.resource_id_resolver.aws_integration[stack] # Drift detection properties drift_detection_ignore_state = try(local.stack_configs[stack].drift_detection_ignore_state, var.drift_detection_ignore_state) @@ -354,24 +338,70 @@ locals { } } - resolved_space_ids = { - for stack in local.stacks : stack => coalesce( - try(local.stack_configs[stack].space_id, null), # space_id always takes precedence since it's the most explicit - try(local.space_name_to_id[local.stack_configs[stack].space_name], null), # Then try to look up space_name from the stack.yaml to ID - var.space_id, - try(local.space_name_to_id[var.space_name], null), # Then try to look up the space_name global variable to ID - local.root_space_id # If no space_id or space_name is provided, default to the root space - ) + ############### + # Resource Name to ID Resolver + #(e.g. space_name -> space_id so users can use the human readable name rather than the ID in configs) + ############### + name_to_id_mappings = { + space = { + for space in data.spacelift_spaces.all.spaces : + space.name => space.space_id + } + worker_pool = { + for pool in data.spacelift_worker_pools.all.worker_pools : + pool.name => pool.worker_pool_id + } + aws_integration = { + for integration in data.spacelift_aws_integrations.all.integrations : + integration.name => integration.integration_id + } + } + + resource_id_resolver_config = { + space = { + id_attr = "space_id" + name_attr = "space_name" + default_value = local.root_space_id + } + worker_pool = { + id_attr = "worker_pool_id" + name_attr = "worker_pool_name" + default_value = null + } + aws_integration = { + id_attr = "aws_integration_id" + name_attr = "aws_integration_name" + default_value = null + } } - # Resolve worker_pool_id if worker_pool_name is provided - resolved_worker_pool_ids = { - for stack in local.stacks : stack => try(coalesce( - try(local.stack_configs[stack].worker_pool_id, null), # worker_pool_id always takes precedence since it's the most explicit - try(local.worker_pool_name_to_id[local.stack_configs[stack].worker_pool_name], null), # Then try to look up worker_pool_name from the stack.yaml to ID - var.worker_pool_id, # Then try to use the global variable worker_pool_id - try(local.worker_pool_name_to_id[var.worker_pool_name], null), # Then try to look up the global variable worker_pool_name to ID - ), null) # If no worker_pool_id or worker_pool_name is provided, default to null + var_lookup = { # We need this map to dynamically access vars like var.space_id when config.id_attr = "space_id". TF doesn't support var[dynamic_key] syntax, downside of it not being a full programming language. + space_id = var.space_id + space_name = var.space_name + worker_pool_id = var.worker_pool_id + worker_pool_name = var.worker_pool_name + aws_integration_id = var.aws_integration_id + aws_integration_name = var.aws_integration_name + } + + # How it works: + # 1. Loops through each resource type (space, worker_pool, aws_integration) + # 2. For each stack, tries to resolve the ID using coalesce() with this precedence: stack ID > stack name > global ID > global name > default + # Example for space resolution on stack "my-stack": + # 1. Check local.stack_configs["my-stack"]["space_id"] (direct ID from YAML) + # 2. Check local.name_to_id_mappings["space"][local.stack_configs["my-stack"]["space_name"]] (name→ID from YAML) + # 3. Check var.space_id (global module variable) + # 4. Check local.name_to_id_mappings["space"][var.space_name] (global name→ID) + # 5. Fall back to local.root_space_id ("root") + resource_id_resolver = { + for resource_type, config in local.resource_id_resolver_config : resource_type => { + for stack in local.stacks : stack => try(coalesce( + try(local.stack_configs[stack][config.id_attr], null), # Direct stack-level ID always takes precedence + try(local.name_to_id_mappings[resource_type][local.stack_configs[stack][config.name_attr]], null), # Direct stack-level name resolution + local.var_lookup[config.id_attr], # Global variable ID + try(local.name_to_id_mappings[resource_type][local.var_lookup[config.name_attr]], null), # Global variable name resolution + ), config.default_value) # Resource-specific default + } } ## Filter integration + drift detection stacks @@ -390,12 +420,6 @@ locals { } } -check "spaces_enforce_mutual_exclusivity" { - assert { - condition = var.space_id == null || var.space_name == null - error_message = "space_id and space_name are mutually exclusive." - } -} # Perform deep merge for common configurations and stack configurations module "deep" { @@ -447,12 +471,12 @@ resource "spacelift_stack" "default" { protect_from_deletion = local.stack_property_resolver[each.key].protect_from_deletion repository = local.stack_property_resolver[each.key].repository runner_image = local.stack_property_resolver[each.key].runner_image - space_id = local.resolved_space_ids[each.key] + space_id = local.resource_id_resolver.space[each.key] terraform_smart_sanitization = local.stack_property_resolver[each.key].terraform_smart_sanitization terraform_version = local.stack_property_resolver[each.key].terraform_version terraform_workflow_tool = var.terraform_workflow_tool terraform_workspace = local.configs[each.key].terraform_workspace - worker_pool_id = local.resolved_worker_pool_ids[each.key] + worker_pool_id = local.resource_id_resolver.worker_pool[each.key] # Usage of `templatestring` requires OpenTofu 1.7 and Terraform 1.9 or later. description = coalesce( diff --git a/stack-config.schema.json b/stack-config.schema.json index 53b8f20..dbbe4c4 100644 --- a/stack-config.schema.json +++ b/stack-config.schema.json @@ -209,6 +209,10 @@ "type": "string", "description": "AWS integration ID" }, + "aws_integration_name": { + "type": "string", + "description": "AWS integration name, this will be translated to an aws_integration_id. Mutually exclusive with aws_integration_id" + }, "drift_detection_enabled": { "type": "boolean", "description": "Whether to enable drift detection" diff --git a/tests/fixtures/multi-instance/root-module-a/stacks/test.yaml b/tests/fixtures/multi-instance/root-module-a/stacks/test.yaml index 77ba164..e57c8c6 100644 --- a/tests/fixtures/multi-instance/root-module-a/stacks/test.yaml +++ b/tests/fixtures/multi-instance/root-module-a/stacks/test.yaml @@ -2,5 +2,6 @@ kind: StackConfigV1 stack_settings: space_id: direct-space-id-stack-yaml # Tests direct space_id precedence over global variable space_id worker_pool_name: mp-ue1-automation-spft-priv-workers # Tests worker_pool_name gets translated to worker_pool_id + aws_integration_name: mp-automation-755965222190 # Tests aws_integration_name gets translated to aws_integration_id labels: - test_label diff --git a/tests/main.tftest.hcl b/tests/main.tftest.hcl index c8fb1dc..cc0cb3b 100644 --- a/tests/main.tftest.hcl +++ b/tests/main.tftest.hcl @@ -848,8 +848,8 @@ run "test_space_name_resolves_to_correct_id" { command = plan assert { - condition = local.resolved_space_ids["root-module-a-default-example"] == "mp-aws-automation-01JK7A21DW1YH3Q64JHS3RYNP9" # For the `masterpointio.app.spacelift.io` - error_message = "Space name not resolving to correct ID: ${jsonencode(local.resolved_space_ids)}" + condition = local.resource_id_resolver.space["root-module-a-default-example"] == "mp-aws-automation-01JK7A21DW1YH3Q64JHS3RYNP9" # For the `masterpointio.app.spacelift.io` + error_message = "Space name not resolving to correct ID: ${jsonencode(local.resource_id_resolver.space)}" } } @@ -862,8 +862,8 @@ run "test_space_id_takes_precedence_over_space_id_global_variable" { } assert { - condition = local.resolved_space_ids["root-module-a-test"] == "direct-space-id-stack-yaml" - error_message = "Space ID from stack settings not taking precedence over global variable space ID: ${jsonencode(local.resolved_space_ids)}" + condition = local.resource_id_resolver.space["root-module-a-test"] == "direct-space-id-stack-yaml" + error_message = "Space ID from stack settings not taking precedence over global variable space ID: ${jsonencode(local.resource_id_resolver.space)}" } } @@ -1009,8 +1009,8 @@ run "test_worker_pool_name_resolves_to_correct_id" { command = plan assert { - condition = local.resolved_worker_pool_ids["root-module-a-test"] == "01K3VABYB4FBXNV24KN4A4EKC8" # For the `mp-ue1-automation-spft-priv-workers` in our `mp-infra` Spacelift account - error_message = "Worker pool name not resolving to correct ID: ${jsonencode(local.resolved_worker_pool_ids)}" + condition = local.resource_id_resolver.worker_pool["root-module-a-test"] == "01K3VABYB4FBXNV24KN4A4EKC8" # For the `mp-ue1-automation-spft-priv-workers` in our `mp-infra` Spacelift account + error_message = "Worker pool name not resolving to correct ID: ${jsonencode(local.resource_id_resolver.worker_pool)}" } } @@ -1023,7 +1023,31 @@ run "test_worker_pool_name_takes_precedence_over_worker_pool_name_global_variabl } assert { - condition = local.resolved_worker_pool_ids["root-module-a-test"] == "01K3VABYB4FBXNV24KN4A4EKC8" # For the `mp-ue1-automation-spft-priv-workers` in our `mp-infra` Spacelift account - error_message = "Worker pool name from stack settings not taking precedence over global variable worker_pool_name: ${jsonencode(local.resolved_worker_pool_ids)}" + condition = local.resource_id_resolver.worker_pool["root-module-a-test"] == "01K3VABYB4FBXNV24KN4A4EKC8" # For the `mp-ue1-automation-spft-priv-workers` in our `mp-infra` Spacelift account + error_message = "Worker pool name from stack settings not taking precedence over global variable worker_pool_name: ${jsonencode(local.resource_id_resolver.worker_pool)}" + } +} + +# Test that aws_integration_name from stack settings resolves to correct ID +run "test_aws_integration_name_resolves_to_correct_id" { + command = plan + + assert { + condition = local.resource_id_resolver.aws_integration["root-module-a-test"] == "01JEC7ZACVKHTSVY4NF8QNZVVB" # For the `mp-automation-755965222190` in our `mp-infra` Spacelift account + error_message = "AWS integration name not resolving to correct ID: ${jsonencode(local.resource_id_resolver.aws_integration)}" + } +} + +# Test that aws_integration_name from stack settings takes precedence over aws_integration_name global variable +run "test_aws_integration_name_takes_precedence_over_aws_integration_name_global_variable" { + command = plan + + variables { + aws_integration_name = "some-other-aws-integration" + } + + assert { + condition = local.resource_id_resolver.aws_integration["root-module-a-test"] == "01JEC7ZACVKHTSVY4NF8QNZVVB" # For the `mp-automation-755965222190` in our `mp-infra` Spacelift account + error_message = "AWS integration name from stack settings not taking precedence over global variable aws_integration_name: ${jsonencode(local.resource_id_resolver.aws_integration)}" } } diff --git a/tests/single-instance.tftest.hcl b/tests/single-instance.tftest.hcl index 4f15318..b43cd37 100644 --- a/tests/single-instance.tftest.hcl +++ b/tests/single-instance.tftest.hcl @@ -145,8 +145,8 @@ run "test_default_space_id_is_used" { command = plan assert { - condition = local.resolved_space_ids["root-module-a"] == "root" - error_message = "Default space_id (root) was not used when no other values provided: ${jsonencode(local.resolved_space_ids)}" + condition = local.resource_id_resolver.space["root-module-a"] == "root" + error_message = "Default space_id (root) was not used when no other values provided: ${jsonencode(local.resource_id_resolver.space)}" } } @@ -155,7 +155,7 @@ run "test_space_id_is_used_from_stack_yaml" { command = plan assert { - condition = local.resolved_space_ids["root-module-b"] == "some-space-id" - error_message = "Space ID from stack.yaml is not being used: ${jsonencode(local.resolved_space_ids)}" + condition = local.resource_id_resolver.space["root-module-b"] == "some-space-id" + error_message = "Space ID from stack.yaml is not being used: ${jsonencode(local.resource_id_resolver.space)}" } } diff --git a/variables.tf b/variables.tf index 9545efb..2fbd434 100644 --- a/variables.tf +++ b/variables.tf @@ -122,6 +122,12 @@ variable "aws_integration_id" { default = null } +variable "aws_integration_name" { + type = string + description = "Name of the AWS integration to attach, which will be resolved to aws_integration_id. We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues." + default = null +} + variable "aws_integration_attachment_read" { type = bool description = "Indicates whether this attachment is used for read operations." @@ -347,7 +353,7 @@ variable "space_id" { variable "space_name" { type = string - description = "Place the created stacks in the specified space_name. Mutually exclusive with space_id." + description = "Place the created stacks in the specified space_name. Mutually exclusive with space_id. We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues." default = null } @@ -371,6 +377,7 @@ variable "worker_pool_id" { description = <<-EOT ID of the worker pool to use. Mutually exclusive with worker_pool_name. NOTE: worker_pool_name or worker_pool_id is required when using a self-hosted instance of Spacelift. + We recommend using names rather than IDs to improve clarity & readability. Since Spacelift enforces unique names, you can rely on names as identifiers without worrying about duplication issues. EOT default = null }