Skip to content

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

  1. Clone xms-cloud-platform-code and open it in the dev container (VS Code: Reopen in Container). It has PowerShell 7, Terraform, the Azure CLI and Pester.
  2. The dev container mounts ../config, relative to the code repository, at /workspaces/config. Clone a configuration repository next to the code as config, or use the Demo configuration in config/Demo/canary.
  3. Copy .env.example to .env and set MCP_CONFIG, for example MCP_CONFIG=/workspaces/xms-cloud-platform-code/config/Demo/canary/mcp.json.
  4. Install the git hooks: lefthook install.
  5. 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. Use create_* / use_*_module booleans for optional features and *_id inputs for dependencies.
  • Use dynamic blocks for optional nested configuration.
  • Build names from scaffold_company_short_name, scaffold_environment and scaffold_location_short_name, and add an overwrite_*_name variable so customers with existing names can keep them.
  • If the feature reads JSON from the configuration repository, take a *_relative_path variable and build the path as "${var.config_path}/${var.<x>_relative_path}". Remember that fileset() 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:

  1. the Terraform that reads it, with a default when the property is absent;
  2. an optional property in src/Xebia.Mcp/Files/json/spokeschema.json;
  3. 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/, template adr-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 in Functions/Private/<Domain>/.
  • Comment-based help with .SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, .NOTES.
  • begin/clean blocks that dot-source begin.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:

  1. With the flag off: the plan shows no changes. This proves backwards compatibility.
  2. With the flag on: the plan shows only the new resources, and the apply works.

5. Pull request and CI

  1. Commit with Conventional Commits (feat(connectivity): add …, fix(ps): …), in US English (docs/coding-standards/).
  2. Open a pull request to main. Describe the change and the flag, and link the work item.
  3. Put the version bump in the merge message: +semver: minor for a feature, +semver: patch for a fix, +semver: major for a breaking change, +semver: none for docs only. See Releasing.
  4. CI runs:

    Platform Pipeline What it does
    Azure DevOps pull-request.yaml (PR to main) 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 canary
    GitHub ci.yml (PR touching infra/deploy/**) Provider pin check
  5. A reviewer checks: flag default off, moved blocks for renames, unit tests, both ADO and GitHub changed, docs updated.

  6. Merge. If the fix must also go to a supported version, cherry-pick it to the release/vX.Y branch (see Releasing).