Skip to content
Merged
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -347,6 +348,7 @@ If you have many remote repositories that you need to manage via this pattern, y
| <a name="input_aws_integration_attachment_write"></a> [aws\_integration\_attachment\_write](#input\_aws\_integration\_attachment\_write) | Indicates whether this attachment is used for write operations. | `bool` | `true` | no |
| <a name="input_aws_integration_enabled"></a> [aws\_integration\_enabled](#input\_aws\_integration\_enabled) | Indicates whether the AWS integration is enabled. | `bool` | `false` | no |
| <a name="input_aws_integration_id"></a> [aws\_integration\_id](#input\_aws\_integration\_id) | ID of the AWS integration to attach. | `string` | `null` | no |
| <a name="input_aws_integration_name"></a> [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 |
| <a name="input_azure_devops"></a> [azure\_devops](#input\_azure\_devops) | The Azure DevOps integration settings | <pre>object({<br/> project = string<br/> id = optional(string)<br/> })</pre> | `null` | no |
| <a name="input_before_apply"></a> [before\_apply](#input\_before\_apply) | List of before-apply scripts | `list(string)` | `[]` | no |
| <a name="input_before_destroy"></a> [before\_destroy](#input\_before\_destroy) | List of before-destroy scripts | `list(string)` | `[]` | no |
Expand Down Expand Up @@ -379,12 +381,12 @@ If you have many remote repositories that you need to manage via this pattern, y
| <a name="input_runner_image"></a> [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 |
| <a name="input_runtime_overrides"></a> [runtime\_overrides](#input\_runtime\_overrides) | Runtime overrides that are merged into the stack config.<br/> This allows for per-root-module overrides of the stack resources at runtime<br/> so you have more flexibility beyond the variable defaults and the static stack config files.<br/> Keys are the root module names and values match the StackConfig schema.<br/> See `stack-config.schema.json` for full details on the schema and<br/> `tests/fixtures/multi-instance/root-module-a/stacks/default-example.yaml` for a complete example. | `any` | `{}` | no |
| <a name="input_space_id"></a> [space\_id](#input\_space\_id) | Place the created stacks in the specified space\_id. Mutually exclusive with space\_name. | `string` | `null` | no |
| <a name="input_space_name"></a> [space\_name](#input\_space\_name) | Place the created stacks in the specified space\_name. Mutually exclusive with space\_id. | `string` | `null` | no |
| <a name="input_space_name"></a> [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 |
| <a name="input_spaces"></a> [spaces](#input\_spaces) | A map of Spacelift Spaces to create | <pre>map(object({<br/> description = optional(string, null)<br/> inherit_entities = optional(bool, false)<br/> labels = optional(list(string), null)<br/> parent_space_id = optional(string, "root")<br/> }))</pre> | `{}` | no |
| <a name="input_terraform_smart_sanitization"></a> [terraform\_smart\_sanitization](#input\_terraform\_smart\_sanitization) | Indicates whether runs on this will use terraform's sensitive value system to sanitize<br/>the outputs of Terraform state and plans in spacelift instead of sanitizing all fields. | `bool` | `false` | no |
| <a name="input_terraform_version"></a> [terraform\_version](#input\_terraform\_version) | OpenTofu/Terraform version to use. Defaults to the latest available version of the `terraform_workflow_tool`. | `string` | `null` | no |
| <a name="input_terraform_workflow_tool"></a> [terraform\_workflow\_tool](#input\_terraform\_workflow\_tool) | Defines the tool that will be used to execute the workflow.<br/>This can be one of OPEN\_TOFU, TERRAFORM\_FOSS or CUSTOM. | `string` | `"OPEN_TOFU"` | no |
| <a name="input_worker_pool_id"></a> [worker\_pool\_id](#input\_worker\_pool\_id) | ID of the worker pool to use. Mutually exclusive with worker\_pool\_name.<br/>NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift. | `string` | `null` | no |
| <a name="input_worker_pool_id"></a> [worker\_pool\_id](#input\_worker\_pool\_id) | ID of the worker pool to use. Mutually exclusive with worker\_pool\_name.<br/>NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift.<br/>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 |
| <a name="input_worker_pool_name"></a> [worker\_pool\_name](#input\_worker\_pool\_name) | Name of the worker pool to use. Mutually exclusive with worker\_pool\_id.<br/>NOTE: worker\_pool\_name or worker\_pool\_id is required when using a self-hosted instance of Spacelift. | `string` | `null` | no |

## Outputs
Expand Down
42 changes: 42 additions & 0 deletions checks.tf
Original file line number Diff line number Diff line change
@@ -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."
}
}
5 changes: 2 additions & 3 deletions data.tf
Original file line number Diff line number Diff line change
@@ -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
Expand Down
106 changes: 65 additions & 41 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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" {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions stack-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading