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 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/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 +} 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", 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)}" + } +} diff --git a/modules/meshstack/noop/meshstack_integration.tf b/modules/meshstack/noop/meshstack_integration.tf index eeb7dd7f..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,166 @@ 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. + 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 +272,100 @@ 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 - } + inputs = local.noop_inputs + outputs = local.noop_outputs + } +} - "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" +# 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") } } - 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" + # 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}") } - 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" + 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}") } - debug_input_files_json = { - assignment_type = "NONE" - display_name = "Input Files as JSON for debugging" + 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 } } @@ -250,7 +375,7 @@ terraform { required_providers { meshstack = { source = "meshcloud/meshstack" - version = ">= 0.21.0" + version = ">= 0.25.2" } } }