Building New Functionality¶
New functionality is a change in the code repository. A change that only needs different values is a configuration change; see Config. Every new feature follows the backwards compatibility rules: opt-in, default off.
flowchart LR
a["1. Module"] --> b["2. Wire into a layer<br/>+ feature flag"]
b --> c["3. Test"]
c --> d["4. Deploy to dev<br/>(local / Demo canary)"]
d --> e["5. PR + CI"]
e --> f["Release"]
0. Set up your environment¶
- Clone
xms-cloud-platform-codeand open it in the dev container (VS Code: Reopen in Container). It has PowerShell 7, Terraform, the Azure CLI and Pester. - The dev container mounts
../config, relative to the code repository, at/workspaces/config. Clone a configuration repository next to the code asconfig, or use the Demo configuration inconfig/Demo/canary. - Copy
.env.exampleto.envand setMCP_CONFIG, for exampleMCP_CONFIG=/workspaces/xms-cloud-platform-code/config/Demo/canary/mcp.json. - Install the git hooks:
lefthook install. - Branch from
main.feature/<name>gets a GitVersion label (-<name>);users/<initials>/<name>is the common convention for personal work.
1. Modules¶
Terraform is organized in three levels:
| Level | Folder | Rule |
|---|---|---|
| Module | infra/modules/<name>/ |
One resource type or a tight group. No knowledge of layers or config files. Files: main.tf, variables.tf, output.tf/outputs.tf, versions.tf, optional tests/. |
| Template | infra/templates/<name>/ |
Composes modules into a feature (for example application_gateway, virtual_hub). Computes names from the scaffold_* variables. |
| Layer | infra/deploy/<layer>/ |
Root module with backend, providers and variables. Calls templates behind feature flags. Variables are split by feature: variables_<feature>.tf. |
Guidelines (see also .agents/skills/terraform-style-guide in the code repository):
- Run
terraform fmt. Usecreate_*/use_*_modulebooleans for optional features and*_idinputs for dependencies. - Use
dynamicblocks for optional nested configuration. - Build names from
scaffold_company_short_name,scaffold_environmentandscaffold_location_short_name, and add anoverwrite_*_namevariable so customers with existing names can keep them. - If the feature reads JSON from the configuration repository, take a
*_relative_pathvariable and build the path as"${var.config_path}/${var.<x>_relative_path}". Remember thatfileset()on a wrong path returns nothing. - Pin providers to an exact version in root modules. The pin check fails otherwise.
Spoke features¶
Spoke Terraform lives in src/Xebia.Mcp/Files/terraform/spoke-canvas/ (deploy/subscription, deploy/spoke,
modules/). A new spoke property needs:
- the Terraform that reads it, with a default when the property is absent;
- an optional property in
src/Xebia.Mcp/Files/json/spokeschema.json; - a row in the spoke JSON reference.
2. Wire it into a layer, behind a flag¶
# infra/deploy/connectivity/variables_my_feature.tf
variable "use_my_feature_module" {
description = "Deploy my feature. Default false keeps existing deployments unchanged."
type = bool
default = false
}
# infra/deploy/connectivity/main.tf
module "my_feature" {
source = "../../templates/my_feature"
count = var.use_my_feature_module ? 1 : 0
# ...
}
Then:
- add the flag and its companion variables to the Demo configuration (
config/Demo/canary/<region>/<layer>.auto.tfvars); - add the flag to the feature flags reference;
- if consumers or operators will request it, add or extend a runbook under Common requests;
- if the decision is architectural, write an ADR (
docs/adr/, templateadr-template.md).
Orchestration changes¶
Logic that decides what runs (if/else, loops, matrices, parsing) belongs in a PowerShell function in
src/Xebia.Mcp/Functions/, not in YAML (ADR-0011, CONTRIBUTING.md):
- Public functions in
Functions/Public/<Domain>/, internal ones inFunctions/Private/<Domain>/. - Comment-based help with
.SYNOPSIS,.DESCRIPTION,.PARAMETER,.EXAMPLE,.NOTES. begin/cleanblocks that dot-sourcebegin.ps1/clean.ps1.- Change the Azure DevOps template and the GitHub workflow in the same pull request.
3. Test¶
| Test | Where | How to run | Runs in |
|---|---|---|---|
| Terraform validate + docs | every layer | automatic on commit | lefthook pre-commit |
| Terraform module tests | infra/modules/<name>/tests/*.tftest.hcl (with mock_provider) |
terraform -chdir=infra/modules/<name> test, or automatic for staged modules (scripts/Run-TerraformTest.ps1) |
lefthook pre-commit |
| Provider pin check | infra/deploy/** |
pwsh scripts/Test-TerraformRootProviderPins.ps1 |
lefthook + GitHub CI |
| PowerShell unit tests | Functions/**/Tests/<Function>.Unit.Tests.ps1 |
pwsh ./dev/dev.ps1 -Action Test |
Prepare step of every pipeline |
| Parity checks | module-wide (file name = function name, every public function has a unit test, …) | pwsh ./dev/dev.ps1 -Action Test |
Prepare step |
| Config tests | src/Xebia.Mcp/Tests/{Config,SpokeConfig,Smoke} |
pwsh ./dev/dev.ps1 -Action Test (uses MCP_CONFIG) |
Prepare step of every pipeline |
Every public function needs a unit test; Assert-XebiaPublicFunctionUnitTestParity fails otherwise. Mock Azure CLI and
Terraform calls.
4. Deploy to dev¶
The development target is the Demo tenant, configuration config/Demo/canary in the code repository.
Locally (fast feedback, your own Azure login):
Import-Module ./src/Xebia.Mcp/Xebia.Mcp.psd1 -Force
Initialize-XebiaDotEnv
Sync-XebiaInfraToMcpModule -Mappings @(@{ Description = 'cloud-platform'; Source = './infra'; Target = 'terraform/cloud-platform/' }) -WhatIf:$false
$config = Resolve-XebiaMcpConfiguration -Path $env:MCP_CONFIG
# Plan one layer (WhatIf on: plan, no apply) — same as dev.ps1 does by default
$global:WhatIfPreference = $true
Deploy-XebiaLocal -Config $config -DeployName connectivity -Region weu
# Plan and apply
$global:WhatIfPreference = $false
Deploy-XebiaLocal -Config $config -DeployName connectivity -Region weu
(dev/dev.ps1 -Action HubDeploy is the intended shortcut but is broken on main, see
Known issues.)
Via workflow/pipeline (same as customers): run the code repository's own entry pipelines (hub.yaml, spoke.yaml)
from your branch against the Demo configuration. Use mode: Plan or ApproveModifications.
Check two things:
- With the flag off: the plan shows no changes. This proves backwards compatibility.
- With the flag on: the plan shows only the new resources, and the apply works.
5. Pull request and CI¶
- Commit with Conventional Commits (
feat(connectivity): add …,fix(ps): …), in US English (docs/coding-standards/). - Open a pull request to
main. Describe the change and the flag, and link the work item. - Put the version bump in the merge message:
+semver: minorfor a feature,+semver: patchfor a fix,+semver: majorfor a breaking change,+semver: nonefor docs only. See Releasing. -
CI runs:
Platform Pipeline What it does Azure DevOps pull-request.yaml(PR tomain)Builds the container from the PR branch, then runs the hub (deploy and destroy) and spoke (deploy and destroy, glob **/*str*.json) stages in Plan / WhatIf against canaryGitHub ci.yml(PR touchinginfra/deploy/**)Provider pin check -
A reviewer checks: flag default off,
movedblocks for renames, unit tests, both ADO and GitHub changed, docs updated. - Merge. If the fix must also go to a supported version, cherry-pick it to the
release/vX.Ybranch (see Releasing).