diff --git a/.agents/skills/aws-backplane.md b/.agents/references/aws-backplane.md similarity index 100% rename from .agents/skills/aws-backplane.md rename to .agents/references/aws-backplane.md diff --git a/.agents/skills/azure-backplane.md b/.agents/references/azure-backplane.md similarity index 100% rename from .agents/skills/azure-backplane.md rename to .agents/references/azure-backplane.md diff --git a/.agents/skills/bbd-readme.md b/.agents/references/bbd-readme.md similarity index 100% rename from .agents/skills/bbd-readme.md rename to .agents/references/bbd-readme.md diff --git a/.agents/references/stackit-backplane.md b/.agents/references/stackit-backplane.md new file mode 100644 index 00000000..80805d3e --- /dev/null +++ b/.agents/references/stackit-backplane.md @@ -0,0 +1,165 @@ +--- +description: STACKIT backplane identity conventions for meshstack-hub modules under modules/stackit/. Covers service account + key pattern, required variables/outputs, provider configuration, meshstack_integration.tf wiring, and the STACKIT backplane checklist. +--- + +# STACKIT Backplane Identity Conventions + +STACKIT backplanes **must** use a **service account with a long-lived key** as the automation +principal for building block execution. The key JSON is provisioned in the backplane and injected +as a sensitive static input into the building block definition. + +## Rationale + +- **Self-contained credentials**: The service account and its key are provisioned once in the + backplane Terraform module. The key JSON is a single credential that bundles the service account + email, key ID, and private key — no extra wiring needed. +- **Least-privilege**: Each building block gets its own service account with exactly the roles it + needs (project-scoped or organization-scoped). +- **No provider configuration in backplane**: The backplane module does not include a `provider.tf`. + Authentication for the backplane itself is configured by the caller (e.g. the platform team running + `tofu apply` or the integration runtime). +- **Sensitive by default**: The `service_account_key_json` output is marked `sensitive = true`. + meshStack's STATIC input wiring uses the `sensitive.argument.secret_value` field to ensure the + key is stored and transmitted as a secret. + + +## Implementation Pattern + +```hcl +# backplane/main.tf — service account + key + role assignments + +resource "stackit_service_account" "backplane" { + project_id = var.project_id + name = "mesh-" +} + +resource "stackit_service_account_key" "backplane" { + project_id = var.project_id + service_account_email = stackit_service_account.backplane.email +} + +# Project-scoped role assignment (use this for project-level resources): +resource "stackit_authorization_project_role_assignment" "this" { + resource_id = var.project_id + role = "" + subject = stackit_service_account.backplane.email +} + +# Organization-scoped role assignment (use this for org-level resources): +resource "stackit_authorization_organization_role_assignment" "this" { + resource_id = var.organization_id + role = "" + subject = stackit_service_account.backplane.email +} +``` + + +## Backplane Outputs (STACKIT) + +Every STACKIT backplane must output the service account key JSON: + +```hcl +output "service_account_key_json" { + value = stackit_service_account_key.backplane.json + description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." + sensitive = true +} +``` + +Additional outputs (e.g. `project_id`, resource IDs) can be added as needed. + +## Backplane Variables (STACKIT) + +All STACKIT backplanes require at minimum: + +```hcl +variable "project_id" { + type = string + nullable = false + description = "STACKIT project ID where the service account will be created." +} +``` + +Backplanes that manage organization-level resources also require: + +```hcl +variable "organization_id" { + type = string + nullable = false + description = "STACKIT organization ID where the service account will be granted permissions." +} +``` + + +## Buildingblock Provider Configuration + +The buildingblock `provider.tf` must use `service_account_key` for authentication. +Do **not** use `service_account_email` alone — it does not authenticate. + +```hcl +# buildingblock/provider.tf +provider "stackit" { + service_account_key = var.service_account_key_json + # Add any extra provider flags required by the resources (e.g. enable_beta_resources, experiments): + # enable_beta_resources = true + # experiments = ["some-feature"] +} +``` + +## Buildingblock Variable + +```hcl +variable "service_account_key_json" { + type = string + nullable = false + sensitive = true + description = "Service account key JSON for authenticating the STACKIT provider." +} +``` + +The key JSON bundles the service account email — do **not** add a separate `service_account_email` +variable when `service_account_key_json` is present. + +## `meshstack_integration.tf` Wiring (STACKIT) + +Pass the key from the backplane as a **STATIC sensitive** input: + +```hcl +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/stackit//backplane?ref=${var.hub.git_ref}" + + project_id = var.stackit_project_id + # organization_id = var.stackit_organization_id # if org-scoped roles are needed +} + +# Inside meshstack_backplane_definition version_spec.inputs: +service_account_key_json = { + display_name = "Service Account Key JSON" + description = "Service account key JSON for authenticating the STACKIT provider." + type = "STRING" + assignment_type = "STATIC" + sensitive = { + argument = { + secret_value = module.backplane.service_account_key_json + } + } +} +``` + +## What to Avoid + +- ❌ `service_account_email` alone in the provider — missing authentication credential +- ❌ Long-lived `STACKIT_SERVICE_ACCOUNT_TOKEN` injected via env var — not reproducible across runs +- ❌ Hardcoded key values in integration files +- ❌ Non-sensitive output for `service_account_key_json` — always mark it `sensitive = true` + +## Checklist for STACKIT Backplanes + +- [ ] `stackit_service_account` resource present +- [ ] `stackit_service_account_key` resource present (same project as the service account) +- [ ] Required role assignments present (`stackit_authorization_project_role_assignment` or `stackit_authorization_organization_role_assignment`) +- [ ] `service_account_key_json` output marked `sensitive = true` +- [ ] Buildingblock `provider.tf` uses `service_account_key = var.service_account_key_json` +- [ ] Buildingblock `variables.tf` has `service_account_key_json` (sensitive, nullable = false) +- [ ] No separate `service_account_email` variable in buildingblock when key is present +- [ ] `meshstack_integration.tf` wires key via `sensitive.argument.secret_value` diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md new file mode 100644 index 00000000..ab535a5c --- /dev/null +++ b/.agents/skills/e2e-test/SKILL.md @@ -0,0 +1,211 @@ +--- +name: e2e-test +description: > + Write, run, and debug hub e2e tests for meshstack-hub modules. Use when asked to add, fix, or run + an end-to-end smoke test for any building block module. Covers structure, test_context wiring, + conventions, the new-test checklist, running via the smoke-test runner, and debugging failures. +--- + +# Hub E2E Test Skill + +This skill is the authoritative reference for hub e2e tests. Modules that can be smoke-tested +against a live meshStack instance include an `e2e/` directory alongside the module root. Tests are +run by the meshstack-smoke-test repo (`../meshstack-smoke-test`). + +For the runner architecture, commands, and conventions, see +[`../meshstack-smoke-test/AGENTS.md`](../../../../meshstack-smoke-test/AGENTS.md). + +--- + +## Structure + +``` +modules/// +└── e2e/ + ├── main.tf # Test root module — sources the meshstack_integration.tf and creates a building block instance + ├── terraform.tf # required_providers block (no version pins needed here) + └── tests/ + └── .tftest.hcl # tftest assertions on building block outputs +``` + +--- + +## `test_context` field inventory + +`e2e/main.tf` declares a single `variable "test_context"` object. The `test_context` output is +defined in +[`../meshstack-smoke-test/modules/test_context/main.tf`](../../../../meshstack-smoke-test/modules/test_context/main.tf). +Declare only the fields your module actually uses, but **at minimum** include `hub_git_ref`, +`workspace`, `project`, and `name_suffix`: + +| Field | Description | +|---|---| +| `hub_git_ref` | Committed SHA of the meshstack-hub checkout — always include | +| `workspace` | `"smoke-test"` — shared smoke-test workspace | +| `project` | smoke-test project identifier | +| `name_suffix` | Timestamp string (e.g. `20260608143022`) — include in resource names for uniqueness | + +Cloud resource IDs always live under `fixtures` — use `var.test_context.fixtures.stackit.project_id`, +not a flat `stackit_project_id` field on `test_context` (that pattern is outdated). + +For additional secrets not in `test_context` (SA keys, tokens), declare separate top-level +`sensitive` variables — `setup-env.sh` in meshstack-smoke-test provides them via `TF_VAR_*`. + +```hcl +variable "test_context" { + type = object({ + hub_git_ref = string + workspace = string + project = string + name_suffix = string + + fixtures = object({ + stackit = object({ + project_id = string + mesh_tenant_id = string + }) + }) + }) + nullable = false +} +``` + +--- + +## `e2e/main.tf` conventions + +- Source the module under test using a **relative path** to the module root (where + `meshstack_integration.tf` lives), **not** a GitHub URL. This ensures tests run against the local + branch without requiring a push. Map the module's flat provider inputs from `fixtures`: + +```hcl +module "my_stackit_module" { + source = "../" # relative path to the meshstack_integration.tf root + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + hub = { + git_ref = var.test_context.hub_git_ref # always use hub_git_ref — never hardcode "main" + bbd_draft = true + } + stackit_project_id = var.test_context.fixtures.stackit.project_id +} +``` + +- When the module under test **depends on other Hub modules** (e.g. a starterkit that composes a + git-repository and connector module), also source those dependencies using **relative paths** + (e.g. `"../../stackit/git-repository"`, `"../forgejo-connector"`). + +- Create a `meshstack_building_block_v2` resource to exercise the building block end-to-end. Pass + `module..building_block_definition.version_ref` **directly** — do not unwrap it as + `{ uuid = module..building_block_definition.version_ref.uuid }`: + +```hcl +resource "meshstack_building_block_v2" "this" { + wait_for_completion = true + spec = { + building_block_definition_version_ref = module.my_module.building_block_definition.version_ref + + display_name = "smoke-test--${var.test_context.name_suffix}" + target_ref = { + kind = "meshWorkspace" + identifier = var.test_context.workspace + } + inputs = { ... } + } +} +``` + +### Workspace-level vs tenant-level `target_ref` + +```hcl +# Workspace-level building block (no cloud tenant): +target_ref = { + kind = "meshWorkspace" + name = var.test_context.workspace +} + +# Tenant-level building block (cloud tenant required): +target_ref = { + kind = "meshTenant" + uuid = var.test_context.fixtures.azure.mesh_tenant_id +} +``` + +--- + +## `e2e/tests/*.tftest.hcl` conventions + +- Name the file `__hub.tftest.hcl` (e.g. `building_block_noop_hub.tftest.hcl`). +- Always assert `status.status == "SUCCEEDED"` as the first check. +- Assert meaningful output values (URLs, strings, booleans) to validate the building block executed + correctly. +- Use `file("${path.root}/tests/.expected.*")` for large expected values (JSON, Markdown) to + keep assertions readable. + +--- + +## Running tests + +From `../meshstack-smoke-test` after `source setup-env.sh`: + +```bash +task hub:e2e:run MODULE=stackit/storage-bucket +task hub:e2e:run MODULE=stackit/storage-bucket TF_LOG=debug +task hub:e2e:run MODULE=azure/resource-group FILTER=tests/azure_resource_group_hub.tftest.hcl +task hub:e2e # run all hub e2e tests +``` + +The runner: applies `modules/test_context` to resolve `hub_git_ref` from the committed SHA, exports +its output as a temp `.tfvars.json`, then runs `tofu test` in the module's `e2e/` directory. + +--- + +## Debugging + +**Hub changes must be pushed before running.** The runner resolves `hub_git_ref` from the current +commit SHA and verifies it exists on a remote branch: + +``` +ERROR: Hub commit has not been pushed to any remote branch. +``` + +Fix: push your branch first. Uncommitted local changes only produce a warning — the test still runs +against the committed SHA. Only the `e2e/` directory itself is executed from local disk. + +**Errored test state.** If `tofu test` fails mid-apply, OpenTofu writes `e2e/errored_test.tfstate`. +Clean up: + +```bash +cd modules///e2e +tofu state list -state=errored_test.tfstate +rm errored_test.tfstate # after manual cleanup if needed +``` + +**Manual run (bypass the runner).** Useful for passing extra `-var` flags or iterating quickly: + +```bash +# From meshstack-smoke-test: produce the var-file +tofu -chdir=modules/test_context apply -auto-approve -var="hub_dir=$(pwd)/../meshstack-hub" +ctx=$(tofu -chdir=modules/test_context output -json test_context) +printf '{"test_context":%s}\n' "$ctx" > /tmp/test-vars.tfvars.json + +# From meshstack-hub: run test directly +cd modules///e2e +tofu init -upgrade -var-file=/tmp/test-vars.tfvars.json +tofu test -var-file=/tmp/test-vars.tfvars.json -var="my_secret=value" +``` + +--- + +## Checklist for New E2E Tests + +- [ ] `e2e/` directory exists at the module root +- [ ] `variable "test_context"` includes `hub_git_ref`, `workspace`, `project`, `name_suffix` +- [ ] Cloud resource IDs sourced from `var.test_context.fixtures.*` (not flat `test_context` fields) +- [ ] Module sourced via relative path (not a GitHub URL) +- [ ] `hub.git_ref = var.test_context.hub_git_ref` — no hardcoded `"main"` +- [ ] `building_block_definition_version_ref` uses the full `version_ref` object directly +- [ ] `meshstack_building_block_v2` has `wait_for_completion = true` +- [ ] tftest.hcl asserts `status.status == "SUCCEEDED"` and key outputs diff --git a/.agents/skills/fix-scorecard.md b/.agents/skills/fix-scorecard.md deleted file mode 100644 index 3559139a..00000000 --- a/.agents/skills/fix-scorecard.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Workflow for fixing scorecard violations in meshstack-hub modules. Use when asked to fix scorecard checks, resolve violations, or improve module maturity scores. ---- - -# Fixing Scorecard Violations - -When asked to fix scorecard violations for a module, follow this workflow: - -## Workflow - -1. **Identify violations** — run the scorecard for the target module: - ```sh - node tools/scorecard/scorecard.mjs --module=/ - ``` - -2. **Get the fix prompt** — run with `--fix` to get a structured list of what to fix and where the instructions are: - ```sh - node tools/scorecard/scorecard.mjs --module=/ --fix - ``` - The output lists each failing check ID, its category, and a link to the relevant section in `AGENTS.md` or the `.agents/skills/` files that explains the correct convention. - -3. **Apply fixes** — for each failing check, read the referenced instruction section and apply the required changes to the module files. - -4. **Verify** — re-run the scorecard after each set of fixes: - ```sh - node tools/scorecard/scorecard.mjs --module=/ - ``` - Repeat until all checks show ✅. - -5. **Commit** — once all checks pass, commit the changes with a message like: - ``` - fix(/): resolve scorecard violations - ``` - -## Notes - -- Fix one category at a time (Core → Integration → Azure Backplane → Testing) to avoid regressions. -- The `logo` check requires a `buildingblock/logo.png` file. If one is missing, generate one using an AI image generator with a flat-design, white-background icon that represents the service, then resize to 256×256 px and optimise with `pngquant`. -- The `e2e_tests` and `e2e_tftest` checks are aspirational — creating a full e2e test is a larger task. Check with the module owner before adding e2e tests. -- Do not mark any check as passing by changing the check logic in `scorecard.mjs`. Fix the module, not the check. diff --git a/.agents/skills/module/SKILL.md b/.agents/skills/module/SKILL.md new file mode 100644 index 00000000..6e377ace --- /dev/null +++ b/.agents/skills/module/SKILL.md @@ -0,0 +1,95 @@ +--- +name: module +description: > + Create or update a meshstack-hub building block module. Covers the full lifecycle: module + structure, backplane identity (per cloud provider), BBD readme, and scorecard compliance. + Use when asked to create a new module, add or fix a backplane, write the readme, or resolve + scorecard violations. +--- + +# Module Skill + +This skill drives two workflows: **creating** a new building block module and **keeping modules up +to date** with the latest conventions (scorecard fixes). For backplane identity details and readme +conventions, see the reference files in `.agents/references/`. + +--- + +## Workflow: Creating a New Module + +1. **Determine scope** — identify the cloud provider and service name → module path `modules///` + +2. **Create the directory structure** (AGENTS.md § Module Structure): + ``` + modules/// + ├── backplane/ # omit if no cloud-side setup needed + ├── buildingblock/ + └── meshstack_integration.tf + ``` + +3. **Implement `buildingblock/`** — `main.tf`, `variables.tf`, `outputs.tf`, `versions.tf`, `provider.tf`, `README.md` (with YAML front-matter), `logo.png` + +4. **Implement `backplane/`** (if needed) — read the provider-specific reference: + - AWS → `.agents/references/aws-backplane.md` + - Azure → `.agents/references/azure-backplane.md` + - STACKIT → `.agents/references/stackit-backplane.md` + +5. **Write the BBD readme** → `.agents/references/bbd-readme.md` + +6. **Write `meshstack_integration.tf`** — follow AGENTS.md § `meshstack_integration.tf` Conventions + +7. **Validate**: + ```sh + terraform validate # in buildingblock/ and backplane/ if present + ``` + +8. **Run scorecard** and iterate until all checks pass: + ```sh + tools/scorecard/scorecard.mjs --module=/ + ``` + +--- + +## Workflow: Fixing Scorecard Violations + +1. **Identify violations**: + ```sh + tools/scorecard/scorecard.mjs --module=/ + ``` + +2. **Get fix hints** — structured list of failing checks with references to the relevant docs: + ```sh + tools/scorecard/scorecard.mjs --module=/ --fix + ``` + +3. **Apply fixes** — for each failing check, read the referenced section and fix the module. Work category by category: **Core → Integration → Azure Backplane → Testing**. + +4. **Verify** after each category: + ```sh + tools/scorecard/scorecard.mjs --module=/ + ``` + Repeat until all checks show ✅. + +5. **Commit**: + ``` + fix(/): resolve scorecard violations + ``` + +### Scorecard fix notes + +- **`logo` check**: requires `buildingblock/logo.png` (256×256 px, flat-design, white-background icon). Generate with an AI image tool if missing, then resize and optimise with `pngquant`. +- **`e2e_tests` / `e2e_tftest`**: creating a full e2e test is a larger task — check with the module owner before adding. See `.agents/skills/e2e-test/SKILL.md`. +- **Never** fix a check by editing the check logic in `scorecard.mjs` — fix the module. + +--- + +## Key references + +| Topic | Reference | +|---|---| +| Module structure & `meshstack_integration.tf` | AGENTS.md | +| BBD readme | `.agents/references/bbd-readme.md` | +| AWS backplane identity | `.agents/references/aws-backplane.md` | +| Azure backplane identity | `.agents/references/azure-backplane.md` | +| STACKIT backplane identity | `.agents/references/stackit-backplane.md` | +| E2E tests | `.agents/skills/e2e-test/SKILL.md` | diff --git a/.claude/commands b/.claude/skills similarity index 100% rename from .claude/commands rename to .claude/skills diff --git a/AGENTS.md b/AGENTS.md index 2bedae1a..4051db99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,24 +189,28 @@ node tools/scorecard/scorecard.mjs --module=/ node tools/scorecard/scorecard.mjs --module=/ --fix ``` -To fix violations, see [.agents/skills/fix-scorecard.md](.agents/skills/fix-scorecard.md). +To fix violations, see the `module` skill (`.agents/skills/module/SKILL.md`). --- ## AWS Backplane Identity Conventions -See [.agents/skills/aws-backplane.md](.agents/skills/aws-backplane.md) for the full AWS backplane identity conventions, including WIF (OIDC + IAM role) and cross-account (IAM user + CloudFormation StackSet) patterns, required variables/outputs, and the AWS backplane checklist. +See [.agents/references/aws-backplane.md](.agents/references/aws-backplane.md) for the full AWS backplane identity conventions, including WIF (OIDC + IAM role) and cross-account (IAM user + CloudFormation StackSet) patterns, required variables/outputs, and the AWS backplane checklist. ## Azure Backplane Identity Conventions -See [.agents/skills/azure-backplane.md](.agents/skills/azure-backplane.md) for the full Azure backplane identity conventions, including UAMI patterns, WIF wiring, required variables/outputs, and the Azure backplane checklist. +See [.agents/references/azure-backplane.md](.agents/references/azure-backplane.md) for the full Azure backplane identity conventions, including UAMI patterns, WIF wiring, required variables/outputs, and the Azure backplane checklist. + +## STACKIT Backplane Identity Conventions + +See [.agents/references/stackit-backplane.md](.agents/references/stackit-backplane.md) for the full STACKIT backplane identity conventions, including the service account + key pattern, required variables/outputs, provider configuration, and the STACKIT backplane checklist. --- ## Documentation Requirements -See [.agents/skills/bbd-readme.md](.agents/skills/bbd-readme.md) for the complete BBD readme specification, template, and checklist. +See [.agents/references/bbd-readme.md](.agents/references/bbd-readme.md) for the complete BBD readme specification, template, and checklist. **`buildingblock/README.md`** — must include YAML front-matter: @@ -221,7 +225,7 @@ description: One-sentence description of what the module provisions. **User-facing readme — two patterns depending on module completeness:** -- **Modules with `meshstack_integration.tf`** (full building blocks): user-facing readme lives in the `readme` field of `meshstack_building_block_definition.spec`. Always use `chomp(<<-EOT)` inline — never `file()` or a separate file (one-file copy/paste requirement). See [.agents/skills/bbd-readme.md](.agents/skills/bbd-readme.md) for full spec. +- **Modules with `meshstack_integration.tf`** (full building blocks): user-facing readme lives in the `readme` field of `meshstack_building_block_definition.spec`. Always use `chomp(<<-EOT)` inline — never `file()` or a separate file (one-file copy/paste requirement). See [.agents/references/bbd-readme.md](.agents/references/bbd-readme.md) for full spec. - **Modules without `meshstack_integration.tf`** (standalone building blocks): place the user-facing readme at `buildingblock/APP_TEAM_README.md`. meshStack uses this file as a fallback when no inline readme is available. The same content requirements apply (plain-text description first, usage motivation, examples, shared responsibility table). @@ -309,89 +313,7 @@ getting-started steps, and shared responsibility matrix. Modules that can be smoke-tested against a live meshStack instance should include an `e2e/` directory alongside the module root. -### Structure - -``` -modules/// -└── e2e/ - ├── main.tf # Test root module — sources the meshstack_integration.tf and creates a building block instance - ├── terraform.tf # required_providers block (no version pins needed here) - └── tests/ - └── .tftest.hcl # tftest assertions on building block outputs -``` - -### `e2e/main.tf` Conventions - -- Declare a single `variable "test_context"` object with **at minimum** these fields: - -```hcl -variable "test_context" { - type = object({ - hub_git_ref = string - workspace = string - project = string - name_suffix = string - }) - nullable = false -} -``` - -Add extra fields (e.g. `forgejo_base_url`, provider tokens) as needed for the module under test. - -- Source the module under test using a **relative path** to the module root (where `meshstack_integration.tf` lives), **not** a GitHub URL. This ensures tests run against the local branch without requiring a push. - -```hcl -module "my_module" { - source = "../" # relative path to the meshstack_integration.tf root - meshstack = { - owning_workspace_identifier = var.test_context.workspace - tags = {} - } - hub = { - git_ref = var.test_context.hub_git_ref # always use hub_git_ref — never hardcode "main" - bbd_draft = true - } -} -``` - -- When the module under test **depends on other Hub modules** (e.g. a starterkit that composes a git-repository and connector module), also source those dependencies using **relative paths** (e.g. `"../../stackit/git-repository"`, `"../forgejo-connector"`). - -- Create a `meshstack_building_block_v2` resource to exercise the building block end-to-end: - -```hcl -resource "meshstack_building_block_v2" "this" { - wait_for_completion = true - spec = { - building_block_definition_version_ref = module.my_module.building_block_definition.version_ref - - display_name = "smoke-test--${var.test_context.name_suffix}" - target_ref = { - kind = "meshWorkspace" - identifier = var.test_context.workspace - } - inputs = { ... } - } -} -``` - -Pass `module..building_block_definition.version_ref` **directly** — do not unwrap it as `{ uuid = module..building_block_definition.version_ref.uuid }`. - -### `e2e/tests/*.tftest.hcl` Conventions - -- Name the file `__hub.tftest.hcl` (e.g. `building_block_noop_hub.tftest.hcl`). -- Always assert `status.status == "SUCCEEDED"` as the first check. -- Assert meaningful output values (URLs, strings, booleans) to validate the building block executed correctly. -- Use `file("${path.root}/tests/.expected.*")` for large expected values (JSON, Markdown) to keep assertions readable. - -### Checklist for New E2E Tests - -- [ ] `e2e/` directory exists at the module root -- [ ] `variable "test_context"` includes `hub_git_ref`, `workspace`, `project`, `name_suffix` -- [ ] Module sourced via relative path (not a GitHub URL) -- [ ] `hub.git_ref = var.test_context.hub_git_ref` — no hardcoded `"main"` -- [ ] `building_block_definition_version_ref` uses the full `version_ref` object directly -- [ ] `meshstack_building_block_v2` has `wait_for_completion = true` -- [ ] tftest.hcl asserts `status.status == "SUCCEEDED"` and key outputs +See [.agents/skills/e2e-test/SKILL.md](.agents/skills/e2e-test/SKILL.md) (the `e2e-test` skill) for the full e2e testing conventions, including the `e2e/` structure, `test_context` wiring, `e2e/main.tf` and `*.tftest.hcl` conventions, the new-test checklist, and how to run and debug tests via the smoke-test runner. --- @@ -402,7 +324,7 @@ Pass `module..building_block_definition.version_ref` **directly** — do n - [ ] Provider versions pinned with `~>` - [ ] Variables in `snake_case` with cloud-provider prefix in `meshstack_integration.tf` (e.g. `azure_tenant_id`) - [ ] `buildingblock/README.md` with YAML front-matter -- [ ] BBD `readme` field uses `chomp(<<-EOT)` inline (no `file()`), starts with plain-text description (no `#` heading), and includes usage motivation, 1–2 examples, and a shared responsibility table with ✅ / ❌ — see [.agents/skills/bbd-readme.md](.agents/skills/bbd-readme.md) +- [ ] BBD `readme` field uses `chomp(<<-EOT)` inline (no `file()`), starts with plain-text description (no `#` heading), and includes usage motivation, 1–2 examples, and a shared responsibility table with ✅ / ❌ — see [.agents/references/bbd-readme.md](.agents/references/bbd-readme.md) - [ ] If no `meshstack_integration.tf`: `buildingblock/APP_TEAM_README.md` is present with the same content requirements (plain-text description first, motivation, examples, shared responsibility table) - [ ] `meshstack_integration.tf` declares `meshcloud/meshstack` in `required_providers` - [ ] `meshstack_integration.tf` uses `variable "hub" { type = object({git_ref = string}) }` and `variable "meshstack" { type = object({owning_workspace_identifier = string}) }` @@ -419,4 +341,4 @@ Pass `module..building_block_definition.version_ref` **directly** — do n - [ ] `logo.png` included in `buildingblock/` - [ ] No `documentation_md` output in `backplane/` — use BBD `readme` field and `backplane/README.md` instead - [ ] No trailing whitespace -- [ ] **Azure modules**: also follow the [Azure Backplane Checklist](.agents/skills/azure-backplane.md#checklist-for-azure-backplanes) +- [ ] **Azure modules**: also follow the [Azure Backplane Checklist](.agents/references/azure-backplane.md#checklist-for-azure-backplanes) diff --git a/ci/validate_modules.sh b/ci/validate_modules.sh index c1fd9981..368d37c2 100755 --- a/ci/validate_modules.sh +++ b/ci/validate_modules.sh @@ -115,16 +115,16 @@ fi modules_glob="$modules_path/*/*/buildingblock" -for readme_file in $(find $modules_glob -name 'README.md'); do +for readme_file in $(find $modules_glob -name 'README.md' -not -path '*/.terraform/*'); do check_readme_format "$readme_file" done -for png_file in $(find $modules_glob -name '*.png'); do +for png_file in $(find $modules_glob -name '*.png' -not -path '*/.terraform/*'); do check_png_naming "$png_file" check_png_minimization "$png_file" done -for buildingblock_dir in $(find $modules_glob -type d -name 'buildingblock'); do +for buildingblock_dir in $(find $modules_glob -type d -name 'buildingblock' -not -path '*/.terraform/*'); do check_terraform_files "$buildingblock_dir" done diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index eb4bcb93..8e924374 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -246,7 +246,7 @@ terraform { } stackit = { source = "stackitcloud/stackit" - version = "~> 0.89.0" + version = ">= 0.88.0" } } } diff --git a/modules/stackit/project/backplane/README.md b/modules/stackit/project/backplane/README.md index 17547f32..8b4c3587 100644 --- a/modules/stackit/project/backplane/README.md +++ b/modules/stackit/project/backplane/README.md @@ -33,7 +33,7 @@ module "project_backplane" { | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.11.0 | -| [stackit](#requirement\_stackit) | ~> 0.89.0 | +| [stackit](#requirement\_stackit) | >= 0.88.0 | ## Modules diff --git a/modules/stackit/project/backplane/versions.tf b/modules/stackit/project/backplane/versions.tf index 7187e1b5..706aef93 100644 --- a/modules/stackit/project/backplane/versions.tf +++ b/modules/stackit/project/backplane/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { stackit = { source = "stackitcloud/stackit" - version = "~> 0.89.0" + version = ">= 0.88.0" } } } diff --git a/modules/stackit/spoke-network/backplane/main.tf b/modules/stackit/spoke-network/backplane/main.tf new file mode 100644 index 00000000..da83a9ff --- /dev/null +++ b/modules/stackit/spoke-network/backplane/main.tf @@ -0,0 +1,18 @@ +resource "stackit_service_account" "backplane" { + project_id = var.project_id + name = "mesh-spoke-network" +} + +resource "stackit_service_account_key" "backplane" { + project_id = var.project_id + service_account_email = stackit_service_account.backplane.email +} + +# network.admin at org scope allows managing routing tables in the network area +# and routed networks in tenant projects. Least-privilege alternative: if STACKIT +# introduces a narrower "network.editor" role, prefer that. +resource "stackit_authorization_organization_role_assignment" "network_admin" { + resource_id = var.organization_id + role = "iaas.network.admin" + subject = stackit_service_account.backplane.email +} diff --git a/modules/stackit/spoke-network/backplane/outputs.tf b/modules/stackit/spoke-network/backplane/outputs.tf new file mode 100644 index 00000000..58f9ab5f --- /dev/null +++ b/modules/stackit/spoke-network/backplane/outputs.tf @@ -0,0 +1,10 @@ +output "service_account_email" { + value = stackit_service_account.backplane.email + description = "Email of the service account used by the building block to manage spoke networks." +} + +output "service_account_key_json" { + value = stackit_service_account_key.backplane.json + description = "Service account key JSON for authenticating the STACKIT provider in the buildingblock." + sensitive = true +} diff --git a/modules/stackit/spoke-network/backplane/variables.tf b/modules/stackit/spoke-network/backplane/variables.tf new file mode 100644 index 00000000..879f9d40 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/variables.tf @@ -0,0 +1,11 @@ +variable "project_id" { + type = string + nullable = false + description = "STACKIT project ID where the service account will be created." +} + +variable "organization_id" { + type = string + nullable = false + description = "STACKIT organization ID where the service account will be granted network management permissions." +} diff --git a/modules/stackit/spoke-network/backplane/versions.tf b/modules/stackit/spoke-network/backplane/versions.tf new file mode 100644 index 00000000..706aef93 --- /dev/null +++ b/modules/stackit/spoke-network/backplane/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = ">= 0.88.0" + } + } +} diff --git a/modules/stackit/spoke-network/buildingblock/README.md b/modules/stackit/spoke-network/buildingblock/README.md new file mode 100644 index 00000000..b1e6c656 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/README.md @@ -0,0 +1,31 @@ +--- +name: STACKIT Spoke Network +supportedPlatforms: + - stackit +description: Provisions a routed network in a STACKIT project and attaches it to the platform hub network area. +--- + +# STACKIT Spoke Network — Building Block + +Provisions a routed network in a STACKIT project and attaches it to the platform hub network area. Optionally creates a custom routing table with a default route via a firewall next-hop. + +## Inputs + +| Name | Type | Description | +|------|------|-------------| +| `project_id` | string | Tenant STACKIT project ID (from PLATFORM_TENANT_ID) | +| `organization_id` | string | STACKIT organization ID | +| `network_area_id` | string | Hub network area ID | +| `service_account_key_json` | string (sensitive) | Backplane SA credentials | +| `network_prefix_length` | number | Subnet prefix length (24–28, default 25) | +| `firewall_next_hop_ip` | string | Next-hop IP for default route; null = no routing table | +| `ipv4_nameservers` | string | JSON-encoded nameserver list; null = STACKIT defaults | + +## Outputs + +| Name | Description | +|------|-------------| +| `network_id` | Spoke network ID | +| `network_cidr` | Allocated CIDR block | +| `routing_table_id` | Custom routing table ID (null if no firewall) | +| `summary` | Markdown summary rendered in meshStack | diff --git a/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl b/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl new file mode 100644 index 00000000..62e50d96 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/SUMMARY.md.tftpl @@ -0,0 +1,10 @@ +# Spoke Network + +| Property | Value | +|----------|-------| +| **Network ID** | `${network_id}` | +| **Network CIDR** | `${network_cidr}` | +| **Hub Network Area** | `${network_area_id}` | +%{~ if has_routing_table} +| **Routing Table** | `${routing_table_id}` | +%{~ endif} diff --git a/modules/stackit/spoke-network/buildingblock/logo.png b/modules/stackit/spoke-network/buildingblock/logo.png new file mode 100644 index 00000000..0894e560 Binary files /dev/null and b/modules/stackit/spoke-network/buildingblock/logo.png differ diff --git a/modules/stackit/spoke-network/buildingblock/main.tf b/modules/stackit/spoke-network/buildingblock/main.tf new file mode 100644 index 00000000..98e855b0 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/main.tf @@ -0,0 +1,29 @@ +locals { + nameservers = var.ipv4_nameservers != null && var.ipv4_nameservers != "" ? split(",", var.ipv4_nameservers) : null +} + +resource "stackit_routing_table" "this" { + count = var.firewall_next_hop_ip != null ? 1 : 0 + organization_id = var.organization_id + network_area_id = var.network_area_id + name = "spoke-${var.project_id}" + system_routes = false +} + +resource "stackit_routing_table_route" "this" { + count = var.firewall_next_hop_ip != null ? 1 : 0 + organization_id = var.organization_id + network_area_id = var.network_area_id + routing_table_id = stackit_routing_table.this[0].routing_table_id + destination = { type = "cidrv4", value = "0.0.0.0/0" } + next_hop = { type = "ipv4", value = var.firewall_next_hop_ip } +} + +resource "stackit_network" "this" { + project_id = var.project_id + name = "spoke-routed" + ipv4_prefix_length = var.network_prefix_length + ipv4_nameservers = local.nameservers + routed = true + routing_table_id = var.firewall_next_hop_ip != null ? stackit_routing_table.this[0].routing_table_id : null +} diff --git a/modules/stackit/spoke-network/buildingblock/outputs.tf b/modules/stackit/spoke-network/buildingblock/outputs.tf new file mode 100644 index 00000000..7f864b37 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/outputs.tf @@ -0,0 +1,25 @@ +output "network_id" { + value = stackit_network.this.network_id + description = "ID of the spoke network." +} + +output "network_cidr" { + value = stackit_network.this.ipv4_prefix + description = "Allocated IPv4 CIDR block of the spoke network." +} + +output "routing_table_id" { + value = var.firewall_next_hop_ip != null ? stackit_routing_table.this[0].routing_table_id : null + description = "ID of the custom routing table, or null if no firewall next-hop is configured." +} + +output "summary" { + description = "Summary with spoke network details." + value = templatefile("${path.module}/SUMMARY.md.tftpl", { + network_id = stackit_network.this.network_id + network_cidr = stackit_network.this.ipv4_prefix + network_area_id = var.network_area_id + has_routing_table = var.firewall_next_hop_ip != null + routing_table_id = var.firewall_next_hop_ip != null ? stackit_routing_table.this[0].routing_table_id : "" + }) +} diff --git a/modules/stackit/spoke-network/buildingblock/provider.tf b/modules/stackit/spoke-network/buildingblock/provider.tf new file mode 100644 index 00000000..bf869259 --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/provider.tf @@ -0,0 +1,4 @@ +provider "stackit" { + enable_beta_resources = true + experiments = ["routing-tables", "network"] +} diff --git a/modules/stackit/spoke-network/buildingblock/variables.tf b/modules/stackit/spoke-network/buildingblock/variables.tf new file mode 100644 index 00000000..2ac7589c --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/variables.tf @@ -0,0 +1,46 @@ +# ── Backplane inputs (static, set once per building block definition) ────────── + +variable "project_id" { + type = string + nullable = false + description = "STACKIT project ID of the application team's tenant (injected from PLATFORM_TENANT_ID)." +} + +variable "organization_id" { + type = string + nullable = false + description = "STACKIT organization ID." +} + +variable "network_area_id" { + type = string + nullable = false + description = "STACKIT network area ID of the platform hub. The spoke network will be attached to this area." +} + +variable "firewall_next_hop_ip" { + type = string + default = null + description = "IPv4 address of the firewall next-hop. When set, creates a routing table with a 0.0.0.0/0 default route via this address." +} + +# ── User inputs (set per building block instance) ───────────────────────────── + +variable "network_prefix_length" { + type = number + default = 25 + nullable = false + description = "IPv4 prefix length for the spoke network (24–28). Controls subnet size: /24 = 254 hosts, /25 = 126, /26 = 62, /27 = 30, /28 = 14." + + validation { + condition = var.network_prefix_length >= 24 && var.network_prefix_length <= 28 + error_message = "network_prefix_length must be between 24 and 28 (inclusive)." + } +} + +variable "ipv4_nameservers" { + type = string + default = null + nullable = true + description = "Comma-separated list of IPv4 DNS nameservers, e.g. '8.8.8.8,8.8.4.4'. Leave null to use STACKIT defaults." +} diff --git a/modules/stackit/spoke-network/buildingblock/versions.tf b/modules/stackit/spoke-network/buildingblock/versions.tf new file mode 100644 index 00000000..990450fb --- /dev/null +++ b/modules/stackit/spoke-network/buildingblock/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "~> 0.96.0" + } + } +} diff --git a/modules/stackit/spoke-network/meshstack_integration.tf b/modules/stackit/spoke-network/meshstack_integration.tf new file mode 100644 index 00000000..50caf33b --- /dev/null +++ b/modules/stackit/spoke-network/meshstack_integration.tf @@ -0,0 +1,235 @@ +variable "stackit_project_id" { + type = string + description = "STACKIT project ID where the backplane service account will be created." +} + +variable "stackit_organization_id" { + type = string + description = "STACKIT organization ID." +} + +variable "network_area_id" { + type = string + description = "STACKIT network area ID (from LZA hub) used for spoke network attachment." +} + +variable "firewall_next_hop_ip" { + type = string + default = null + description = "IPv4 address of the firewall next-hop. Pass null if no firewall is configured (route-optional)." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = {} + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of meshcloud/meshstack-hub repo. + `bbd_draft`: If true, allows changing the building block definition for upgrading dependent building blocks. + EOT +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/stackit/spoke-network/backplane?ref=${var.hub.git_ref}" + + project_id = var.stackit_project_id + organization_id = var.stackit_organization_id +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = "STACKIT Spoke Network" + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/${var.hub.git_ref}/modules/stackit/spoke-network/buildingblock/logo.png" + description = "Provisions a routed network in an application team's STACKIT project and attaches it to the platform hub network area." + target_type = "TENANT_LEVEL" + supported_platforms = [{ name = "STACKIT" }] + run_transparency = true + readme = chomp(<<-EOT + This building block provisions a **routed STACKIT network** in your project and attaches it + to the shared platform hub via the network area, enabling corporate connectivity and controlled + internet egress. + + ## 🎯 When to use it + + Use this building block when your application: + - Needs to communicate with other corporate workloads over private IP. + - Should route internet traffic through the platform firewall (when one is configured). + - Requires a dedicated IPv4 subnet within the STACKIT project. + + ## 💡 Usage examples + + **Example 1: Backend service on corporate network** + A microservice needs to call an on-premises API over private IP. Adding the Spoke Network + building block provisions a /25 subnet in your STACKIT project and connects it to the hub, + enabling private routing without exposing the service to the public internet. + + **Example 2: Controlled internet egress** + When the platform firewall is enabled, all outbound traffic from the spoke network passes + through it, allowing the platform team to enforce egress policies across all application teams. + + ## 📊 Shared Responsibility + + | Responsibility | Platform Team | Application Team | + |---|:---:|:---:| + | Provision the routed network | ✅ | ❌ | + | Attach network to hub network area | ✅ | ❌ | + | Configure routing table (when firewall present) | ✅ | ❌ | + | Choose network prefix length | ❌ | ✅ | + | Deploy workloads within the network | ❌ | ✅ | + | Manage security groups and firewall rules per VM | ❌ | ✅ | + EOT + ) + } + + version_spec = { + draft = var.hub.bbd_draft + deletion_mode = "DELETE" + + implementation = { + terraform = { + terraform_version = "1.11.0" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/stackit/spoke-network/buildingblock" + ref_name = var.hub.git_ref + async = false + use_mesh_http_backend_fallback = true + } + } + + inputs = { + project_id = { + display_name = "STACKIT Project ID" + description = "STACKIT project ID of the application team's tenant (set automatically from platform tenant identity)." + type = "STRING" + assignment_type = "PLATFORM_TENANT_ID" + } + + organization_id = { + display_name = "Organization ID" + description = "STACKIT organization ID." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(var.stackit_organization_id) + } + + network_area_id = { + display_name = "Network Area ID" + description = "STACKIT network area ID of the platform hub." + type = "STRING" + assignment_type = "STATIC" + argument = jsonencode(var.network_area_id) + } + + service_account_key_json = { + display_name = "Service Account Key JSON" + description = "Service account key JSON for authenticating the STACKIT provider." + type = "FILE" + assignment_type = "STATIC" + sensitive = { + argument = { + secret_value = "data:application/json;base64,${base64encode(module.backplane.service_account_key_json)}" + secret_version = nonsensitive(sha256(module.backplane.service_account_key_json)) + } + } + } + + STACKIT_SERVICE_ACCOUNT_KEY_PATH = { + display_name = "STACKIT Credentials Path" + description = "Path to the STACKIT service account credentials file." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("./service_account_key_json") + } + + # firewall_next_hop_ip = { + # display_name = "Firewall Next-Hop IP" + # description = "IPv4 address of the firewall next-hop. Null if no firewall is configured." + # type = "STRING" + # assignment_type = "STATIC" + # argument = jsonencode(var.firewall_next_hop_ip) + # } + + network_prefix_length = { + display_name = "Network Prefix Length" + description = "IPv4 prefix length for the spoke network (24–28). Determines subnet size: /24 = 254 hosts, /25 = 126, /26 = 62, /27 = 30, /28 = 14." + type = "INTEGER" + assignment_type = "USER_INPUT" + default_value = "25" + value_validation_regex = "^(24|25|26|27|28)$" + validation_regex_error_message = "Prefix length must be between 24 and 28." + } + + ipv4_nameservers = { + display_name = "DNS Nameservers" + description = "Comma-separated list of IPv4 DNS nameservers, e.g. '8.8.8.8,8.8.4.4'. Leave blank to use STACKIT defaults." + type = "STRING" + assignment_type = "USER_INPUT" + } + } + + outputs = { + network_id = { + display_name = "Network ID" + type = "STRING" + assignment_type = "NONE" + } + + network_cidr = { + display_name = "Network CIDR" + type = "STRING" + assignment_type = "NONE" + } + + routing_table_id = { + display_name = "Routing Table ID" + type = "STRING" + assignment_type = "NONE" + } + + summary = { + display_name = "Summary" + type = "STRING" + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = "~> 0.20.0" + } + stackit = { + source = "stackitcloud/stackit" + version = ">= 0.88.0" + } + } +} diff --git a/modules/stackit/storage-bucket/e2e/main.tf b/modules/stackit/storage-bucket/e2e/main.tf new file mode 100644 index 00000000..72fff2af --- /dev/null +++ b/modules/stackit/storage-bucket/e2e/main.tf @@ -0,0 +1,47 @@ +variable "test_context" { + type = object({ + hub_git_ref = string + workspace = string + project = string + name_suffix = string + + fixtures = object({ + stackit = object({ + project_id = string + mesh_tenant_id = string + }) + }) + }) + nullable = false +} + +module "stackit_storage_bucket" { + source = "../" + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + hub = { + git_ref = var.test_context.hub_git_ref + bbd_draft = true + } + + stackit_project_id = var.test_context.fixtures.stackit.project_id +} + +resource "meshstack_building_block_v2" "this" { + wait_for_completion = true + spec = { + building_block_definition_version_ref = module.stackit_storage_bucket.building_block_definition.version_ref + + display_name = "smoke-test-stackit-storage-bucket-${var.test_context.name_suffix}" + target_ref = { + kind = "meshWorkspace" + name = var.test_context.workspace + } + + inputs = { + bucket_name = { value_string = "smoke-test-bucket-${var.test_context.name_suffix}" } + } + } +} diff --git a/modules/stackit/storage-bucket/e2e/terraform.tf b/modules/stackit/storage-bucket/e2e/terraform.tf new file mode 100644 index 00000000..3d4bedc5 --- /dev/null +++ b/modules/stackit/storage-bucket/e2e/terraform.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + } + } +} diff --git a/modules/stackit/storage-bucket/e2e/tests/building_block_stackit_storage-bucket_hub.tftest.hcl b/modules/stackit/storage-bucket/e2e/tests/building_block_stackit_storage-bucket_hub.tftest.hcl new file mode 100644 index 00000000..e3964fe1 --- /dev/null +++ b/modules/stackit/storage-bucket/e2e/tests/building_block_stackit_storage-bucket_hub.tftest.hcl @@ -0,0 +1,26 @@ +run "building_block_stackit_storage_bucket_hub" { + assert { + condition = meshstack_building_block_v2.this.status.status == "SUCCEEDED" + error_message = "stackit storage-bucket hub building block expected SUCCEEDED, got ${meshstack_building_block_v2.this.status.status}" + } + + assert { + condition = meshstack_building_block_v2.this.status.outputs["bucket_name"].value_string == "smoke-test-bucket-${var.test_context.name_suffix}" + error_message = "stackit storage-bucket hub building block expected bucket_name to be 'smoke-test-bucket-${var.test_context.name_suffix}', got ${meshstack_building_block_v2.this.status.outputs["bucket_name"].value_string}" + } + + assert { + condition = strcontains(meshstack_building_block_v2.this.status.outputs["bucket_url_path_style"].value_string, "smoke-test-bucket-${var.test_context.name_suffix}") + error_message = "stackit storage-bucket hub building block expected bucket_url_path_style to contain bucket name, got ${meshstack_building_block_v2.this.status.outputs["bucket_url_path_style"].value_string}" + } + + assert { + condition = strcontains(meshstack_building_block_v2.this.status.outputs["bucket_url_path_style"].value_string, "object.storage.eu01.onstackit.cloud") + error_message = "stackit storage-bucket hub building block expected bucket_url_path_style to contain STACKIT domain, got ${meshstack_building_block_v2.this.status.outputs["bucket_url_path_style"].value_string}" + } + + assert { + condition = length(meshstack_building_block_v2.this.status.outputs["s3_access_key"].value_string) > 0 + error_message = "stackit storage-bucket hub building block expected non-empty s3_access_key" + } +} diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index 12f6954c..794f215b 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -53,8 +53,9 @@ const CATEGORIES = { // Each detector returns { pass: boolean, detail?: string } const AGENTS = (section) => ({ file: "AGENTS.md", section }); -const AZURE = (section) => ({ file: ".agents/skills/azure-backplane.md", section }); -const BBD_README = (section) => ({ file: ".agents/skills/bbd-readme.md", section }); +const AZURE = (section) => ({ file: ".agents/references/azure-backplane.md", section }); +const BBD_README = (section) => ({ file: ".agents/references/bbd-readme.md", section }); +const E2E = (section) => ({ file: ".agents/skills/e2e-test/SKILL.md", section }); const detectors = [ // ─── Core Structure ───────────────────────────────────────────────────── @@ -483,7 +484,7 @@ const detectors = [ category: "testing", name: "e2e/ test directory exists", emoji: "🧪", - fixRef: AGENTS("end-to-end-testing"), + fixRef: E2E("structure"), fn: (mod) => ({ pass: existsSync(join(mod.path, "e2e")), }), @@ -493,7 +494,7 @@ const detectors = [ category: "testing", name: "e2e/ contains .tftest.hcl files", emoji: "✅", - fixRef: AGENTS("e2etests-tftesthcl-conventions"), + fixRef: E2E("e2eteststftesthcl-conventions"), fn: (mod) => { const e2eDir = join(mod.path, "e2e", "tests"); if (!existsSync(e2eDir)) return { pass: false }; @@ -576,6 +577,67 @@ function discoverModules() { return modules.sort((a, b) => a.id.localeCompare(b.id)); } +// ─── fixRef validator ──────────────────────────────────────────────────────── + +function headingToAnchor(heading) { + return heading + .replace(/^#+\s*/, "") + .replace(/`([^`]*)`/g, "$1") + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-"); +} + +function validateFixRefs() { + const filesToParse = [...new Set(detectors.filter((d) => d.fixRef).map((d) => d.fixRef.file))]; + const markerMap = new Map(); // "file#section" → Set + const allMarkerCheckIds = new Set(); + + for (const relFile of filesToParse) { + const filePath = join(ROOT, relFile); + if (!existsSync(filePath)) continue; + + const lines = readFileSync(filePath, "utf-8").split("\n"); + let pendingIds = null; + + for (const line of lines) { + const m = line.match(//); + if (m) { + pendingIds = m[1].split(",").map((s) => s.trim()).filter(Boolean); + continue; + } + if (pendingIds && /^#{1,6}\s/.test(line)) { + const section = headingToAnchor(line); + markerMap.set(`${relFile}#${section}`, new Set(pendingIds)); + for (const id of pendingIds) allMarkerCheckIds.add(id); + pendingIds = null; + } + } + } + + const errors = []; + const detectorIds = new Set(detectors.map((d) => d.id)); + + for (const d of detectors) { + if (!d.fixRef) continue; + const key = `${d.fixRef.file}#${d.fixRef.section}`; + const checksInSection = markerMap.get(key); + if (!checksInSection) { + errors.push(`fixRef "${key}" not found — no marker before that heading`); + } else if (!checksInSection.has(d.id)) { + errors.push(`check "${d.id}" missing from marker at "${key}" (has: ${[...checksInSection].join(", ")})`); + } + } + + for (const id of allMarkerCheckIds) { + if (!detectorIds.has(id)) { + errors.push(`marker references unknown check "${id}" — no detector with that id`); + } + } + + return errors; +} + // ─── Fix prompt generator ──────────────────────────────────────────────────── function generateFixPrompt(mod, failingChecks) { @@ -619,6 +681,13 @@ function main() { const filterModules = args.filter((a) => a.startsWith("--module=")).map((a) => a.split("=")[1]); const fixMode = args.includes("--fix"); + const fixRefErrors = validateFixRefs(); + if (fixRefErrors.length > 0) { + process.stderr.write("❌ fixRef/marker validation failed:\n"); + for (const e of fixRefErrors) process.stderr.write(` • ${e}\n`); + process.exit(1); + } + let modules = discoverModules(); if (filterProvider) { modules = modules.filter((m) => m.provider === filterProvider);