From 57a8e7636cc6d005af9ed5ec4d89ca7beaede253 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:15:24 +0200 Subject: [PATCH 1/6] feat: read tag inputs in the noop building block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The noop building block is the reference implementation of meshStack's Terraform interface, and TAG inputs are the one input source it did not cover. Three nullable list variables — one per taggable meshObject a tenant-level building block can read — plus their entries in the debug output the e2e tests assert on. No definition passes them yet, so they resolve to null and the existing expected fixture just gains three null keys. Co-Authored-By: Claude Opus 5 (1M context) --- .../meshstack/noop/buildingblock/README.md | 10 ++++++++++ .../meshstack/noop/buildingblock/outputs.tf | 3 +++ .../meshstack/noop/buildingblock/variables.tf | 19 +++++++++++++++++++ ...b.debug_input_variables_json.expected.json | 3 +++ 4 files changed, 35 insertions(+) diff --git a/modules/meshstack/noop/buildingblock/README.md b/modules/meshstack/noop/buildingblock/README.md index eb9e9c60..9ed80f5b 100644 --- a/modules/meshstack/noop/buildingblock/README.md +++ b/modules/meshstack/noop/buildingblock/README.md @@ -36,6 +36,13 @@ Use it to: | `multi_select_json` | `MULTI_SELECT` | `USER_INPUT` | Same as above, as a raw JSON string | | `some-file.yaml` | `FILE` | `STATIC` | Written to working directory; read via `file("some-file.yaml")` | | `sensitive-file.yaml` | `FILE` | `STATIC` (sensitive) | Like above, encrypted at rest | +| `project_tag` | `CODE` | `TAG` | Values of a meshProject tag, resolved by meshStack — `null` when the tag holds no value | +| `payment_method_tag` | `CODE` | `TAG` | Values of a meshPaymentMethod tag on the project's payment method | +| `landing_zone_tag` | `CODE` | `TAG` | Values of a meshLandingZone tag on the tenant's landing zone | + +The three `TAG` inputs are declared by the **tenant-level** definition only. A `TAG` input can +read a workspace, project, payment method or landing zone tag, and a workspace-level definition +has no project — so only a tenant-level one can reach the latter three. ### How FILE Inputs Work @@ -69,10 +76,13 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [flag](#input\_flag) | n/a | `bool` | n/a | yes | +| [landing\_zone\_tag](#input\_landing\_zone\_tag) | n/a | `list(string)` | `null` | no | | [multi\_select](#input\_multi\_select) | n/a | `list(string)` | n/a | yes | | [multi\_select\_json](#input\_multi\_select\_json) | n/a | `string` | n/a | yes | | [num](#input\_num) | n/a | `number` | n/a | yes | | [optional\_text](#input\_optional\_text) | n/a | `string` | `"tf-default-value"` | no | +| [payment\_method\_tag](#input\_payment\_method\_tag) | n/a | `list(string)` | `null` | no | +| [project\_tag](#input\_project\_tag) | n/a | `list(string)` | `null` | no | | [sensitive\_text](#input\_sensitive\_text) | n/a | `string` | n/a | yes | | [sensitive\_yaml](#input\_sensitive\_yaml) | n/a | `any` | n/a | yes | | [single\_select](#input\_single\_select) | n/a | `string` | n/a | yes | diff --git a/modules/meshstack/noop/buildingblock/outputs.tf b/modules/meshstack/noop/buildingblock/outputs.tf index e606ea2b..230eaece 100644 --- a/modules/meshstack/noop/buildingblock/outputs.tf +++ b/modules/meshstack/noop/buildingblock/outputs.tf @@ -113,6 +113,9 @@ output "debug_input_variables_json" { multi_select_json = var.multi_select_json static = var.static static_code = var.static_code + project_tag = var.project_tag + payment_method_tag = var.payment_method_tag + landing_zone_tag = var.landing_zone_tag user_permissions = var.user_permissions user_permissions_json = var.user_permissions_json }) diff --git a/modules/meshstack/noop/buildingblock/variables.tf b/modules/meshstack/noop/buildingblock/variables.tf index 5c562a81..29e013b4 100644 --- a/modules/meshstack/noop/buildingblock/variables.tf +++ b/modules/meshstack/noop/buildingblock/variables.tf @@ -60,3 +60,22 @@ variable "multi_select" { variable "multi_select_json" { type = string } + +# A TAG input resolves to the tag's values, and to null when the tag holds no value on the object it +# is read from — an absent key and an empty one both mean unset. Every TAG input is therefore a +# nullable list, whichever meshObject it reads from. + +variable "project_tag" { + type = list(string) + default = null +} + +variable "payment_method_tag" { + type = list(string) + default = null +} + +variable "landing_zone_tag" { + type = list(string) + default = null +} diff --git a/modules/meshstack/noop/e2e/tests/building_block_noop_hub.debug_input_variables_json.expected.json b/modules/meshstack/noop/e2e/tests/building_block_noop_hub.debug_input_variables_json.expected.json index 444d5eb3..d1085c34 100644 --- a/modules/meshstack/noop/e2e/tests/building_block_noop_hub.debug_input_variables_json.expected.json +++ b/modules/meshstack/noop/e2e/tests/building_block_noop_hub.debug_input_variables_json.expected.json @@ -1,5 +1,6 @@ { "flag": true, + "landing_zone_tag": null, "multi_select": [ "multi1", "multi2" @@ -7,6 +8,8 @@ "multi_select_json": "[\"multi2\",\"multi1\"]", "num": 1, "optional_text":"tf-default-value", + "payment_method_tag": null, + "project_tag": null, "sensitive_text":"Hidden value", "sensitive_yaml":"some: yaml\nother: value\n", "single_select":"single1", From 7e81f2dba91c5c1e0012f03537baf9ba6b77f2f4 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:16:43 +0200 Subject: [PATCH 2/6] refactor: lift the noop definition's inputs and outputs into locals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second definition is about to run the same building block, and that building block's variables have no defaults — so both definitions have to declare the identical input set. Sharing it is what keeps them in step. Pure move: `tofu validate` passes and the definition is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../meshstack/noop/meshstack_integration.tf | 298 +++++++++--------- 1 file changed, 153 insertions(+), 145 deletions(-) diff --git a/modules/meshstack/noop/meshstack_integration.tf b/modules/meshstack/noop/meshstack_integration.tf index eeb7dd7f..afcfc646 100644 --- a/modules/meshstack/noop/meshstack_integration.tf +++ b/modules/meshstack/noop/meshstack_integration.tf @@ -40,6 +40,157 @@ output "building_block_definition" { } } +locals { + # Both definitions run the same building block, whose variables have no defaults, so each one has + # to declare every input. Sharing them keeps the two from drifting apart. + noop_inputs = { + flag = { + assignment_type = "USER_INPUT" + display_name = "Flag" + type = "BOOLEAN" + } + multi_select = { + assignment_type = "USER_INPUT" + display_name = "Multi Select" + selectable_values = ["multi1", "multi2"] + type = "MULTI_SELECT" + } + multi_select_json = { + assignment_type = "USER_INPUT" + display_name = "Multi Select Json" + selectable_values = ["multi1", "multi2"] + type = "MULTI_SELECT" + } + num = { + assignment_type = "USER_INPUT" + display_name = "Num" + type = "INTEGER" + } + optional_text = { + assignment_type = "USER_INPUT" + display_name = "Optional Text" + type = "STRING" + is_optional = true + } + + "sensitive-file.yaml" = { + assignment_type = "STATIC" + display_name = "Sensitive File.yaml" + type = "FILE" + sensitive = { + argument = { + secret_value = "data:application/yaml;base64,c29tZTogaW5wdXQKb3RoZXI6IHZhbHVlCg==" + secret_version = null + } + } + } + sensitive_text = { + assignment_type = "USER_INPUT" + display_name = "Sensitive Text" + type = "STRING" + sensitive = {} + } + sensitive_yaml = { + assignment_type = "STATIC" + display_name = "Sensitive Yaml" + type = "CODE" + sensitive = { + argument = { + secret_value = "some: yaml\nother: value\n" + } + } + } + single_select = { + assignment_type = "USER_INPUT" + display_name = "Single Select" + selectable_values = ["single1", "single2"] + type = "SINGLE_SELECT" + } + "some-file.yaml" = { + assignment_type = "STATIC" + display_name = "Yaml" + type = "FILE" + argument = jsonencode("data:application/yaml;base64,c29tZTogaW5wdXQKb3RoZXI6IHZhbHVlCg==") + } + static = { + argument = jsonencode("A static value") + assignment_type = "STATIC" + display_name = "Static" + type = "STRING" + } + static_code = { + argument = jsonencode(jsonencode({ some : "code" })) + assignment_type = "STATIC" + display_name = "Static Code" + type = "CODE" + } + text = { + assignment_type = "USER_INPUT" + default_value = jsonencode("") + display_name = "Text" + type = "STRING" + } + user_permissions = { + assignment_type = "USER_PERMISSIONS" + display_name = "User Permissions" + type = "CODE" + } + user_permissions_json = { + assignment_type = "USER_PERMISSIONS" + display_name = "User Permissions" + type = "CODE" + } + } + + noop_outputs = { + flag = { + assignment_type = "NONE" + display_name = "Flag" + type = "BOOLEAN" + } + num = { + assignment_type = "NONE" + display_name = "Num" + type = "INTEGER" + } + text = { + assignment_type = "NONE" + display_name = "Text" + type = "STRING" + } + optional_text = { + assignment_type = "NONE" + display_name = "Optional Text" + type = "STRING" + } + static_code = { + assignment_type = "NONE" + display_name = "Static Code" + type = "CODE" + } + resource_url = { + assignment_type = "RESOURCE_URL" + display_name = "Resource URL" + type = "STRING" + } + summary = { + assignment_type = "SUMMARY" + display_name = "Summary" + type = "STRING" + } + debug_input_variables_json = { + assignment_type = "NONE" + display_name = "Input Variables as JSON for debugging" + type = "CODE" + } + debug_input_files_json = { + assignment_type = "NONE" + display_name = "Input Files as JSON for debugging" + type = "CODE" + } + } +} + resource "meshstack_building_block_definition" "this" { metadata = { owned_by_workspace = var.meshstack.owning_workspace_identifier @@ -96,151 +247,8 @@ resource "meshstack_building_block_definition" "this" { pre_run_script = file("${path.module}/buildingblock/prerun.sh") } } - inputs = { - flag = { - assignment_type = "USER_INPUT" - display_name = "Flag" - type = "BOOLEAN" - } - multi_select = { - assignment_type = "USER_INPUT" - display_name = "Multi Select" - selectable_values = ["multi1", "multi2"] - type = "MULTI_SELECT" - } - multi_select_json = { - assignment_type = "USER_INPUT" - display_name = "Multi Select Json" - selectable_values = ["multi1", "multi2"] - type = "MULTI_SELECT" - } - num = { - assignment_type = "USER_INPUT" - display_name = "Num" - type = "INTEGER" - } - optional_text = { - assignment_type = "USER_INPUT" - display_name = "Optional Text" - type = "STRING" - is_optional = true - } - - "sensitive-file.yaml" = { - assignment_type = "STATIC" - display_name = "Sensitive File.yaml" - type = "FILE" - sensitive = { - argument = { - secret_value = "data:application/yaml;base64,c29tZTogaW5wdXQKb3RoZXI6IHZhbHVlCg==" - secret_version = null - } - } - } - sensitive_text = { - assignment_type = "USER_INPUT" - display_name = "Sensitive Text" - type = "STRING" - sensitive = {} - } - sensitive_yaml = { - assignment_type = "STATIC" - display_name = "Sensitive Yaml" - type = "CODE" - sensitive = { - argument = { - secret_value = "some: yaml\nother: value\n" - } - } - } - single_select = { - assignment_type = "USER_INPUT" - display_name = "Single Select" - selectable_values = ["single1", "single2"] - type = "SINGLE_SELECT" - } - "some-file.yaml" = { - assignment_type = "STATIC" - display_name = "Yaml" - type = "FILE" - argument = jsonencode("data:application/yaml;base64,c29tZTogaW5wdXQKb3RoZXI6IHZhbHVlCg==") - } - static = { - argument = jsonencode("A static value") - assignment_type = "STATIC" - display_name = "Static" - type = "STRING" - } - static_code = { - argument = jsonencode(jsonencode({ some : "code" })) - assignment_type = "STATIC" - display_name = "Static Code" - type = "CODE" - } - text = { - assignment_type = "USER_INPUT" - default_value = jsonencode("") - display_name = "Text" - type = "STRING" - } - user_permissions = { - assignment_type = "USER_PERMISSIONS" - display_name = "User Permissions" - type = "CODE" - } - user_permissions_json = { - assignment_type = "USER_PERMISSIONS" - display_name = "User Permissions" - type = "CODE" - } - } - outputs = { - flag = { - assignment_type = "NONE" - display_name = "Flag" - type = "BOOLEAN" - } - num = { - assignment_type = "NONE" - display_name = "Num" - type = "INTEGER" - } - text = { - assignment_type = "NONE" - display_name = "Text" - type = "STRING" - } - optional_text = { - assignment_type = "NONE" - display_name = "Optional Text" - type = "STRING" - } - static_code = { - assignment_type = "NONE" - display_name = "Static Code" - type = "CODE" - } - resource_url = { - assignment_type = "RESOURCE_URL" - display_name = "Resource URL" - type = "STRING" - } - summary = { - assignment_type = "SUMMARY" - display_name = "Summary" - type = "STRING" - } - debug_input_variables_json = { - assignment_type = "NONE" - display_name = "Input Variables as JSON for debugging" - type = "CODE" - } - debug_input_files_json = { - assignment_type = "NONE" - display_name = "Input Files as JSON for debugging" - type = "CODE" - } - } + inputs = local.noop_inputs + outputs = local.noop_outputs } } From f267a9e0d6677276a7d740e32007269c7c5f1990 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:19:54 +0200 Subject: [PATCH 3/6] feat: add an opt-in tenant-level noop definition with TAG inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TAG input reads a tag from the workspace, project, payment method or landing zone the building block belongs to. A workspace-level definition has no project, so only a tenant-level one reaches the latter three — hence a second definition rather than three more inputs on the existing one. It stays off unless a consumer names the tag keys, so nothing changes for anyone importing the module today. The keys are named rather than discovered because a definition stores only the key; meshStack resolves the value per building block. Needs provider 0.25.2, which is where TAG inputs landed. Co-Authored-By: Claude Opus 5 (1M context) --- .../meshstack/noop/meshstack_integration.tf | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/modules/meshstack/noop/meshstack_integration.tf b/modules/meshstack/noop/meshstack_integration.tf index afcfc646..b677a007 100644 --- a/modules/meshstack/noop/meshstack_integration.tf +++ b/modules/meshstack/noop/meshstack_integration.tf @@ -7,6 +7,22 @@ variable "runner_ref" { description = "Optional reference to a meshStack building block runner. When set, building block runs are dispatched to this custom runner. Obtain the value from the backplane module's `runner_ref` output." } +variable "tenant_tag_inputs" { + type = object({ + project_tag_key = string + payment_method_tag_key = string + landing_zone_tag_key = string + platform_type_name = string + }) + default = null + description = <<-EOT + When set, a second, tenant-level definition is published whose inputs read these three meshStack + tags. Tag keys are named rather than discovered, because a definition stores only the key and + meshStack resolves the value per building block. `platform_type_name` names the meshPlatformType + the definition supports, which meshStack requires of every tenant-level definition. + EOT +} + variable "meshstack" { type = object({ owning_workspace_identifier = string @@ -40,6 +56,15 @@ output "building_block_definition" { } } +output "tenant_building_block_definition" { + description = "Tenant-level BBD reading meshStack tags. Null unless `tenant_tag_inputs` is set." + value = var.tenant_tag_inputs == null ? null : { + uuid = one(meshstack_building_block_definition.tenant_tag_inputs).metadata.uuid + version_ref = var.hub.bbd_draft ? one(meshstack_building_block_definition.tenant_tag_inputs).version_latest : one(meshstack_building_block_definition.tenant_tag_inputs).version_latest_release + git_ref = var.hub.git_ref + } +} + locals { # Both definitions run the same building block, whose variables have no defaults, so each one has # to declare every input. Sharing them keeps the two from drifting apart. @@ -252,13 +277,105 @@ resource "meshstack_building_block_definition" "this" { } } +# Only a tenant-level building block has a project, a payment method and a landing zone to read tags +# from — a workspace-level one can reach workspace tags and nothing else. So the TAG inputs need a +# definition of their own. It runs the same building block; the three inputs are the only difference. +resource "meshstack_building_block_definition" "tenant_tag_inputs" { + count = var.tenant_tag_inputs == null ? 0 : 1 + + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "meshStack NoOp Tenant Building Block" + description = "Tenant-level reference building block demonstrating inputs resolved from meshStack tags." + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = var.tenant_tag_inputs.platform_type_name }] + readme = chomp(<<-EOT + The **meshStack NoOp Tenant Building Block** shows how a building block reads metadata meshStack + already governs. Three of its inputs are sourced from tags — on your project, on the payment + method funding it, and on your tenant's landing zone — so nobody has to type a cost center or an + environment classification into a form twice. + + Everything else it does matches the workspace-level NoOp building block: it provisions no real + infrastructure and reports every input it received back as an output. + + ## 🎯 When to use it + + Use this building block when you want to: + - See which tags a tenant-level building block can read, and what their values look like. + - Check that your tag schema reaches your building blocks before you depend on it. + - Confirm that editing a tag makes meshStack re-run the building blocks that read it. + + ## 💡 Usage examples + + **Example 1: Checking a cost center reaches your tooling** + Set the cost center tag on your project, deploy this building block, and read the resolved value + from its outputs — no need to instrument a real building block first. + + **Example 2: Watching a tag change take effect** + Change the tag value and watch meshStack start a new run on its own. The outputs then carry the + new value, which is what a real building block would act on. + + ## 📊 Shared Responsibility + + | Responsibility | Platform Team | Application Team | + |---|:---:|:---:| + | Define the tag schema and which tags this building block reads | ✅ | ❌ | + | Set the tag values on the workspace, project and payment method | ❌ | ✅ | + | Deploy and test the building block | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + deletion_mode = "DELETE" + runner_ref = var.runner_ref + implementation = { + terraform = { + ref_name = var.hub.git_ref + repository_path = "modules/meshstack/noop/buildingblock" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + terraform_version = "1.12.5" + pre_run_script = file("${path.module}/buildingblock/prerun.sh") + } + } + # A TAG input is always CODE and never sensitive — a tag value is a list of strings, and tags are + # visible metadata. The provider rejects both mistakes at plan time. + inputs = merge(local.noop_inputs, { + project_tag = { + assignment_type = "TAG" + display_name = "Project Tag" + type = "CODE" + argument = jsonencode("PROJECT.${var.tenant_tag_inputs.project_tag_key}") + } + payment_method_tag = { + assignment_type = "TAG" + display_name = "Payment Method Tag" + type = "CODE" + argument = jsonencode("PAYMENT_METHOD.${var.tenant_tag_inputs.payment_method_tag_key}") + } + landing_zone_tag = { + assignment_type = "TAG" + display_name = "Landing Zone Tag" + type = "CODE" + argument = jsonencode("LANDING_ZONE.${var.tenant_tag_inputs.landing_zone_tag_key}") + } + }) + outputs = local.noop_outputs + } +} + terraform { required_version = ">= 1.12.0" required_providers { meshstack = { source = "meshcloud/meshstack" - version = ">= 0.21.0" + version = ">= 0.25.2" } } } From ad3f3fa5d83d75bb0785adf1be950a6eb2937e1c Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:25:26 +0200 Subject: [PATCH 4/6] test: add a self-contained meshTenant fixture for the tag e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three tags under test sit on a project, a payment method and a landing zone, and every one of them has to be owned by the test: the provider has no meshstack_project_tag or meshstack_payment_method_tag resource, so a shared fixture object cannot be tagged at all. A custom platform makes that chain reachable with no cloud behind it. The one catch is the tenant: the provider treats it as created once spec.platform_tenant_id is set, and a custom platform has no replicator to set it — hence the mandatory manual building block, which is how the provider's own landing zone tests solve the same problem. Two payment methods, because reassigning which one funds the project is the cheapest of meshStack's four tag-source reassignment triggers. Needs an admin-scoped API key: ADM_TAGDEFINITION_SAVE and ADM_PAYMENTMETHOD_SAVE have no workspace-scoped variant. Co-Authored-By: Claude Opus 5 (1M context) --- modules/meshstack/noop/e2e/tag-inputs/main.tf | 358 ++++++++++++++++++ .../meshstack/noop/e2e/tag-inputs/outputs.tf | 23 ++ .../noop/e2e/tag-inputs/terraform.tf | 13 + .../noop/e2e/tag-inputs/variables.tf | 41 ++ 4 files changed, 435 insertions(+) create mode 100644 modules/meshstack/noop/e2e/tag-inputs/main.tf create mode 100644 modules/meshstack/noop/e2e/tag-inputs/outputs.tf create mode 100644 modules/meshstack/noop/e2e/tag-inputs/terraform.tf create mode 100644 modules/meshstack/noop/e2e/tag-inputs/variables.tf diff --git a/modules/meshstack/noop/e2e/tag-inputs/main.tf b/modules/meshstack/noop/e2e/tag-inputs/main.tf new file mode 100644 index 00000000..27b55338 --- /dev/null +++ b/modules/meshstack/noop/e2e/tag-inputs/main.tf @@ -0,0 +1,358 @@ +locals { + # A platform type identifier can never be reused, not even after the type is deleted, so the run + # suffix is what keeps repeated and concurrent runs from colliding. Uppercase and dashes only. + platform_type_name = "NOOP-TAG-${var.test_context.name_suffix}" + + # The test instance caps a project identifier at 30 characters and requires a stage suffix. + project_identifier = "noop-tag-${var.test_context.name_suffix}-dev" + + # Tag definitions are instance-global, so every key carries the run suffix. They must also differ + # from the instance's mandatory keys below, or the "landing zone offers every value" rule would + # couple the project tag to the landing zone tag and block a change to either on its own. + project_tag_key = "noopProject${var.test_context.name_suffix}" + payment_method_tag_key = "noopPaymentMethod${var.test_context.name_suffix}" + landing_zone_tag_key = "noopLandingZone${var.test_context.name_suffix}" + + # The test workspace makes these tags mandatory, and a landing zone has to offer every value a + # project assigned to it may carry — meshStack rejects both objects otherwise. + landingzone_tags = { + confidentiality = ["Public", "Internal", "Confidential"] + environment = ["dev", "qa", "prod"] + } + + project_tags = { + confidentiality = ["Public"] + environment = ["dev"] + } + + # Each scenario changes as little as possible against the one before it, so a failed assertion + # names one mechanism rather than three. `initial` establishes the values, `changed_values` edits + # all three tags, and `reassigned_payment_method` leaves every tag alone and funds the project from + # the substitute payment method instead — which is a different trigger, not a tag edit. + scenarios = { + initial = { + run_marker = 1 + project_tag_values = ["cc-1000"] + primary_pm_tag_values = ["pm-primary"] + landing_zone_tag_values = ["lz-a", "lz-b"] + funding = "primary" + } + changed_values = { + run_marker = 2 + project_tag_values = ["cc-2000"] + primary_pm_tag_values = ["pm-primary-edited"] + landing_zone_tag_values = ["lz-c"] + funding = "primary" + } + reassigned_payment_method = { + run_marker = 3 + project_tag_values = ["cc-2000"] + primary_pm_tag_values = ["pm-primary-edited"] + landing_zone_tag_values = ["lz-c"] + funding = "substitute" + } + } + + scenario = local.scenarios[var.scenario] + + # Constant, so the reassignment scenario changes the resolved value by changing which payment + # method the project reads from — not by editing a tag. + substitute_pm_tag_values = ["pm-substitute"] + + payment_methods = { + primary = meshstack_payment_method.primary.metadata.name + substitute = meshstack_payment_method.substitute.metadata.name + } +} + +# Tag definitions are admin-scoped meshObjects (ADM_TAGDEFINITION_SAVE has no workspace-scoped +# variant) and global to the instance. The building block definition reads `spec.key` from these +# resources rather than repeating the string, which is also what gets the destroy order right: +# meshStack refuses to delete a tag definition while a building block still reads it. +resource "meshstack_tag_definition" "project" { + spec = { + target_kind = "meshProject" + key = local.project_tag_key + display_name = "NoOp Project Tag" + description = "Smoke test tag read by the tenant-level NoOp building block." + value_type = { string = {} } + mandatory = false + immutable = false + restricted = false + } +} + +resource "meshstack_tag_definition" "payment_method" { + spec = { + target_kind = "meshPaymentMethod" + key = local.payment_method_tag_key + display_name = "NoOp Payment Method Tag" + description = "Smoke test tag read by the tenant-level NoOp building block." + value_type = { string = {} } + mandatory = false + immutable = false + restricted = false + } +} + +resource "meshstack_tag_definition" "landing_zone" { + spec = { + target_kind = "meshLandingZone" + key = local.landing_zone_tag_key + display_name = "NoOp Landing Zone Tag" + description = "Smoke test tag read by the tenant-level NoOp building block." + value_type = { string = {} } + mandatory = false + immutable = false + restricted = false + } +} + +# A custom platform is what makes the tenant this test needs reachable without a cloud: it has no +# replicator, so nothing outside meshStack has to exist or succeed. +resource "meshstack_platform_type" "this" { + metadata = { + name = local.platform_type_name + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs ${var.test_context.name_suffix}" + icon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + } +} + +resource "meshstack_platform" "this" { + metadata = { + name = "noop-tag-${var.test_context.name_suffix}" + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs ${var.test_context.name_suffix}" + description = "Smoke test platform for the tenant-level NoOp building block." + endpoint = "https://hub.meshcloud.io/modules/meshstack/noop" + location_ref = { name = "global" } + + availability = { + restriction = "RESTRICTED" + publication_state = "UNPUBLISHED" + restricted_to_workspaces = [var.test_context.workspace] + } + + config = { + custom = { + platform_type_ref = meshstack_platform_type.this.ref + } + } + } + + lifecycle { + ignore_changes = [spec.availability] + } +} + +# Without this, `meshstack_tenant` never finishes creating: the provider treats a tenant as created +# once `spec.platform_tenant_id` is set, and a custom platform has no replicator to set it. A +# mandatory building block whose only output is assigned PLATFORM_TENANT_ID supplies it instead. All +# its inputs are STATIC, so the manual run completes with no operator action. +resource "meshstack_building_block_definition" "platform_tenant_id" { + metadata = { + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs Platform Tenant ID ${var.test_context.name_suffix}" + description = "Supplies a platform tenant ID so tenants on the custom platform replicate." + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = meshstack_platform_type.this.metadata.name }] + } + + version_spec = { + # Released, not draft: a landing zone's mandatory building block ref resolves the definition's + # latest released version. + draft = false + deletion_mode = "DELETE" + implementation = { manual = {} } + + inputs = { + tenant_id = { + display_name = "Tenant ID" + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode("noop-tag-${var.test_context.name_suffix}") + } + } + + outputs = { + # A manual output's type is derived from the matching input and must not be set. + tenant_id = { + display_name = "Tenant ID" + assignment_type = "PLATFORM_TENANT_ID" + } + } + } +} + +resource "meshstack_landingzone" "this" { + metadata = { + name = "noop-tag-lz-${var.test_context.name_suffix}" + owned_by_workspace = var.test_context.workspace + tags = merge(local.landingzone_tags, { + (meshstack_tag_definition.landing_zone.spec.key) = local.scenario.landing_zone_tag_values + }) + } + + spec = { + display_name = "NoOp Tag Inputs ${var.test_context.name_suffix}" + description = "Smoke test landing zone for the tenant-level NoOp building block." + + # Both automated, so tearing the tenant down needs no operator action. + automate_deletion_approval = true + automate_deletion_replication = true + + platform_ref = meshstack_platform.this.ref + + platform_properties = { + # Nothing to specify for a custom platform, but the block has to be present. + custom = {} + } + + mandatory_building_block_refs = [ + { uuid = meshstack_building_block_definition.platform_tenant_id.metadata.uuid } + ] + } +} + +# Payment methods are admin-scoped too (ADM_PAYMENTMETHOD_SAVE). Two of them, because reassigning +# which one funds the project is the cheapest of meshStack's four tag-source reassignment triggers. +resource "meshstack_payment_method" "primary" { + metadata = { + name = "noop-tag-pm-a-${var.test_context.name_suffix}" + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs Primary ${var.test_context.name_suffix}" + tags = { + (meshstack_tag_definition.payment_method.spec.key) = local.scenario.primary_pm_tag_values + } + } +} + +resource "meshstack_payment_method" "substitute" { + metadata = { + name = "noop-tag-pm-b-${var.test_context.name_suffix}" + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs Substitute ${var.test_context.name_suffix}" + tags = { + (meshstack_tag_definition.payment_method.spec.key) = local.substitute_pm_tag_values + } + } +} + +resource "meshstack_project" "this" { + metadata = { + name = local.project_identifier + owned_by_workspace = var.test_context.workspace + } + + spec = { + display_name = "NoOp Tag Inputs ${var.test_context.name_suffix}" + payment_method_identifier = local.payment_methods[local.scenario.funding] + tags = merge(local.project_tags, { + (meshstack_tag_definition.project.spec.key) = local.scenario.project_tag_values + }) + } +} + +resource "meshstack_tenant" "this" { + # The delete run deprovisions the landing zone's mandatory building block, which has to still be + # there for it. Without this OpenTofu is free to tear the landing zone down in parallel. + depends_on = [meshstack_landingzone.this] + wait_for_completion = true + + metadata = { + owned_by_workspace = var.test_context.workspace + owned_by_project = meshstack_project.this.metadata.name + } + + spec = { + platform_ref = meshstack_platform.this.ref + landing_zone_ref = meshstack_landingzone.this.ref + } +} + +module "noop" { + source = "../../" + + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + hub = { + git_ref = var.test_context.hub_git_ref + bbd_draft = true + } + + tenant_tag_inputs = { + project_tag_key = meshstack_tag_definition.project.spec.key + payment_method_tag_key = meshstack_tag_definition.payment_method.spec.key + landing_zone_tag_key = meshstack_tag_definition.landing_zone.spec.key + platform_type_name = meshstack_platform_type.this.metadata.name + } +} + +resource "time_sleep" "tag_settle" { + depends_on = [ + meshstack_project.this, + meshstack_payment_method.primary, + meshstack_payment_method.substitute, + meshstack_landingzone.this, + ] + + create_duration = var.tag_settle_duration + + # Replacement is destroy-then-create, which is what makes the wait happen again on every scenario + # rather than only the first. + triggers = { + scenario = var.scenario + } +} + +resource "meshstack_building_block" "this" { + # Ordering, not just teardown: the tag writes have to land before the update that triggers the run + # this test reads, and the settle has to sit between the two. + depends_on = [ + module.noop, + time_sleep.tag_settle, + ] + + wait_for_completion = true + + spec = { + building_block_definition_version_ref = { uuid = module.noop.tenant_building_block_definition.version_ref.uuid } + + display_name = "smoke-test-noop-tag-inputs-${var.test_context.name_suffix}" + target_ref = { + kind = "meshTenant" + uuid = meshstack_tenant.this.metadata.uuid + } + + inputs = { + flag = { value = jsonencode(true) } + # The run marker. A TAG input is resolved by meshStack, so nothing in the configuration makes + # the provider notice a tag change; bumping an input it does track is what makes it issue an + # update and wait for the run that resolves the current tags. + num = { value = jsonencode(local.scenario.run_marker) } + text = { value = jsonencode("Hello, Tag World!") } + sensitive_text = { sensitive = { secret_value = "Hidden value" } } + single_select = { value = jsonencode("single1") } + multi_select = { value = jsonencode(["multi1", "multi2"]) } + multi_select_json = { value = jsonencode(["multi2", "multi1"]) } + } + } +} diff --git a/modules/meshstack/noop/e2e/tag-inputs/outputs.tf b/modules/meshstack/noop/e2e/tag-inputs/outputs.tf new file mode 100644 index 00000000..700483fa --- /dev/null +++ b/modules/meshstack/noop/e2e/tag-inputs/outputs.tf @@ -0,0 +1,23 @@ +locals { + # Every building block output arrives JSON-encoded, and a CODE output carries JSON of its own — so + # the debug map decodes twice. `try` so that a run which produced no outputs at all fails on the + # status assertion, which says what went wrong, rather than on evaluating this. + received_inputs = try( + jsondecode(jsondecode(meshstack_building_block.this.status.outputs["debug_input_variables_json"].value)), + {} + ) +} + +output "resolved_tags" { + description = "The three TAG inputs as the building block run received them, decoded." + value = { + project = try(local.received_inputs["project_tag"], null) + payment_method = try(local.received_inputs["payment_method_tag"], null) + landing_zone = try(local.received_inputs["landing_zone_tag"], null) + } +} + +output "building_block_status" { + description = "Run status of the building block under test." + value = meshstack_building_block.this.status.status +} diff --git a/modules/meshstack/noop/e2e/tag-inputs/terraform.tf b/modules/meshstack/noop/e2e/tag-inputs/terraform.tf new file mode 100644 index 00000000..26ad09e4 --- /dev/null +++ b/modules/meshstack/noop/e2e/tag-inputs/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + } + time = { + source = "hashicorp/time" + version = ">= 0.12.0" + } + } +} diff --git a/modules/meshstack/noop/e2e/tag-inputs/variables.tf b/modules/meshstack/noop/e2e/tag-inputs/variables.tf new file mode 100644 index 00000000..989eefd4 --- /dev/null +++ b/modules/meshstack/noop/e2e/tag-inputs/variables.tf @@ -0,0 +1,41 @@ +variable "test_context" { + type = object({ + hub_git_ref = string + workspace = string + name_suffix = string + }) + nullable = false + + # No `bbd_version_ref`: this test owns the platform, landing zone, payment methods and project its + # tenant lives on, and the invocation protocol has no way to hand it a foundation's equivalents. So + # only build-from-source mode is supported. +} + +variable "scenario" { + type = string + default = "initial" + + validation { + condition = contains(["initial", "changed_values", "reassigned_payment_method"], var.scenario) + error_message = "scenario must be one of initial, changed_values or reassigned_payment_method." + } + + description = <<-EOT + Which tag state to apply. The test file drives this from one `run` block to the next, so the three + scenarios share state and each one is a change to the previous — which is the whole point: a tag + input can only be seen to follow a tag if the tag changes under a building block that already exists. + EOT +} + +variable "tag_settle_duration" { + type = string + default = "0s" + + description = <<-EOT + How long to wait after writing the tags before touching the building block. meshStack starts a run + of its own when a tag a building block reads changes, and that run competes with the one this test + triggers: it can reach a terminal state first and have its outputs read instead, and meshStack + rejects an update while a run is in flight. Zero on the first apply, where no building block exists + yet to re-run. + EOT +} From ba0a1ff859a075c6ec29ac828e36387aaa9633e6 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:25:34 +0200 Subject: [PATCH 5/6] test: assert tag inputs resolve on create, on edit and on reassignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three runs sharing one file's state, which is new for this repo: a tag input can only be seen to follow a tag if the tag changes under a building block that already exists. The runs prove that the value a tag holds now reaches the next run. They do not prove the tag edit alone triggered it — the provider exposes no way to attribute a run to an edit, so each run bumps an input the provider does track and waits for a run of its own. The third run edits no tag at all and still sees a new value, because the project is funded from another payment method. Co-Authored-By: Claude Opus 5 (1M context) --- ...lding_block_noop_tag_inputs_hub.tftest.hcl | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 modules/meshstack/noop/e2e/tests/building_block_noop_tag_inputs_hub.tftest.hcl diff --git a/modules/meshstack/noop/e2e/tests/building_block_noop_tag_inputs_hub.tftest.hcl b/modules/meshstack/noop/e2e/tests/building_block_noop_tag_inputs_hub.tftest.hcl new file mode 100644 index 00000000..151d63c1 --- /dev/null +++ b/modules/meshstack/noop/e2e/tests/building_block_noop_tag_inputs_hub.tftest.hcl @@ -0,0 +1,107 @@ +# The one e2e file in this repo whose `run` blocks deliberately share state. A TAG input can only be +# seen to follow a tag if the tag changes under a building block that already exists, so each run +# here is a change to the one before it rather than a fresh case. See the exception in +# .agents/skills/e2e-test/SKILL.md. +# +# What these runs prove: the value a tag holds now reaches the next building block run. What they do +# not prove: that the tag edit alone triggered that run. meshStack does trigger one, but the provider +# exposes no way to attribute a run to an edit, so each run here bumps the `num` input to make the +# provider issue an update and wait for a run of its own. + +variables { + # Long enough for the run meshStack starts on its own to get out of the way. It competes with the + # one this test triggers: it can reach a terminal state first and have its outputs read instead, + # and meshStack rejects an update while a run is in flight. + tag_settle_duration = "180s" +} + +run "tag_inputs_resolve_on_create" { + module { + source = "./tag-inputs" + } + + variables { + scenario = "initial" + # No building block exists yet, so there is no run of meshStack's to wait out. + tag_settle_duration = "0s" + } + + assert { + condition = output.building_block_status == "SUCCEEDED" + error_message = "noop tag inputs building block expected SUCCEEDED, got ${output.building_block_status}" + } + + assert { + condition = output.resolved_tags.project == ["cc-1000"] + error_message = "expected the PROJECT tag to resolve to [\"cc-1000\"], got ${jsonencode(output.resolved_tags.project)}" + } + + assert { + condition = output.resolved_tags.payment_method == ["pm-primary"] + error_message = "expected the PAYMENT_METHOD tag to resolve to [\"pm-primary\"], got ${jsonencode(output.resolved_tags.payment_method)}" + } + + # Two values, because a tag value is a list and a single-element list would not show that. + assert { + condition = output.resolved_tags.landing_zone == ["lz-a", "lz-b"] + error_message = "expected the LANDING_ZONE tag to resolve to [\"lz-a\", \"lz-b\"], got ${jsonencode(output.resolved_tags.landing_zone)}" + } +} + +run "tag_value_changes_resolve_on_the_next_run" { + module { + source = "./tag-inputs" + } + + variables { + scenario = "changed_values" + } + + assert { + condition = output.building_block_status == "SUCCEEDED" + error_message = "noop tag inputs building block expected SUCCEEDED after the tag edits, got ${output.building_block_status}" + } + + assert { + condition = output.resolved_tags.project == ["cc-2000"] + error_message = "expected the edited PROJECT tag to resolve to [\"cc-2000\"], got ${jsonencode(output.resolved_tags.project)}" + } + + assert { + condition = output.resolved_tags.payment_method == ["pm-primary-edited"] + error_message = "expected the edited PAYMENT_METHOD tag to resolve to [\"pm-primary-edited\"], got ${jsonencode(output.resolved_tags.payment_method)}" + } + + assert { + condition = output.resolved_tags.landing_zone == ["lz-c"] + error_message = "expected the edited LANDING_ZONE tag to resolve to [\"lz-c\"], got ${jsonencode(output.resolved_tags.landing_zone)}" + } +} + +run "reassigned_payment_method_resolves_its_own_tag" { + module { + source = "./tag-inputs" + } + + variables { + scenario = "reassigned_payment_method" + } + + assert { + condition = output.building_block_status == "SUCCEEDED" + error_message = "noop tag inputs building block expected SUCCEEDED after the reassignment, got ${output.building_block_status}" + } + + # No tag was edited in this run — the project is funded from the substitute payment method instead, + # so the input resolves a different object's tag. + assert { + condition = output.resolved_tags.payment_method == ["pm-substitute"] + error_message = "expected the reassigned PAYMENT_METHOD tag to resolve to [\"pm-substitute\"], got ${jsonencode(output.resolved_tags.payment_method)}" + } + + # Unchanged, which is what makes the assertion above about the reassignment and nothing else. + assert { + condition = output.resolved_tags.project == ["cc-2000"] && output.resolved_tags.landing_zone == ["lz-c"] + error_message = "expected the PROJECT and LANDING_ZONE tags to be untouched by the reassignment, got project=${jsonencode(output.resolved_tags.project)} landing_zone=${jsonencode(output.resolved_tags.landing_zone)}" + } +} From d4de49dc4bc4fa8a5f6e615ec5702fe1a9033a67 Mon Sep 17 00:00:00 2001 From: Thomas Felix Date: Thu, 3 Sep 2026 15:26:08 +0200 Subject: [PATCH 6/6] docs: allow one e2e test file to share state when the subject is a change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule against several run blocks in one file gave two concrete reasons, and neither one applies to a test whose subject is a change to a live building block's surroundings — a tag it reads moving under it. Spelling that out is what keeps the exception from being read as permission to share state in general. It also records the two things such a file has to get right, both of which cost a debugging round: bump an input the provider tracks, and let meshStack's own tag-triggered run get out of the way first. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/e2e-test/SKILL.md | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md index a4264e6c..ff5e9cbc 100644 --- a/.agents/skills/e2e-test/SKILL.md +++ b/.agents/skills/e2e-test/SKILL.md @@ -347,6 +347,39 @@ external resource (the same workflow files in a fixture repository, say) therefo no external locking or concurrency group needed. Verify assumptions like this against the OpenTofu version in use rather than trusting them. +### The exception: a test whose subject *is* a change + +Some behaviour only exists as a change to something that already exists. A `TAG` input is the +example: meshStack resolves it from a tag on the workspace, project, payment method or landing zone, +and re-resolves it when that tag changes or when the object it reads from is reassigned. A fresh +building block per value proves resolution and nothing else — the follow-the-metadata behaviour needs +one building block and a tag that moves under it. That takes shared state, so it takes one file. + +Neither reason above applies to such a test, and saying why is what keeps the exception narrow: + +- It never touches `display_name`, so the rename-without-a-run trap is not in play. +- It never touches `building_block_definition_version_ref`, so the released-version-only upgrade path + is not in play either. The building block's own `spec` identity is fixed for the whole file; only + the objects *around* it change. + +Two things such a file has to get right, both learned the hard way: + +- **Bump an input the provider tracks in every mutating run.** A TAG input is resolved server-side, + so nothing in the configuration tells the provider a tag moved — it plans no change and waits for + nothing. Bumping a `USER_INPUT` makes it issue an update and await a run, and that run resolves the + current tag values. Be explicit in the file about what this does and does not prove: the value a + tag holds now reaches the next run; *not* that the tag edit alone triggered a run. The provider + surfaces no way to attribute a run to an edit. +- **Let meshStack's own run get out of the way first.** meshStack triggers a run when a tag a + building block reads changes. That run competes with the one the test triggers: `awaitRun` polls + the block's aggregate status and stops at the first terminal one, so the wrong run's outputs can + land in state, and meshStack rejects an update issued while a run is in flight. A `time_sleep` + between the tag writes and the building block, replaced per scenario via `triggers`, is enough — + and it belongs in the fixture module, not in a polling script. + +`modules/meshstack/noop/e2e/tests/building_block_noop_tag_inputs_hub.tftest.hcl` is the worked +example. If a test does not clear this bar, use separate files. + --- ## Running tests @@ -484,6 +517,7 @@ source setup-override-provider.sh - [ ] tftest asserts `status.status == "SUCCEEDED"` and key outputs (references `var.test_context.*` directly — non-null in both modes) - [ ] Variant flags (sync/async and similar) are **root variables of the `e2e/` module** with a default, not `test_context` fields - [ ] One `.tftest.hcl` file per variant, pinning the flag in a file-level `variables` block — never several `run` blocks sharing one file's state +- [ ] …unless the subject of the test *is* a change to a live building block's surroundings — then one file with shared state, and see [The exception](#the-exception-a-test-whose-subject-is-a-change) for the two things it must get right - [ ] Writes into a long-lived shared fixture go to a per-run ephemeral slice named from `name_suffix`, owned by the `e2e/` module and included in the building block's `depends_on` - [ ] State the live apply cannot reach is covered by a mocked `__unit.tftest.hcl` in `e2e/tests/`, targeting `module { source = "../buildingblock" }` — and only where the two bars are cleared (worth testing, unreachable by the apply); anything the apply *can* reach is an assertion on the apply instead - [ ] Every mocked run is mutation-checked: break the module, watch the run fail