Reference

How the platform works

How a change in a service repo becomes a running workload in an environment. The building blocks, the files each repo owns, what the tooling turns them into, and the two pipelines that move them.

State as of 28 October 2026Examples use the first product on the platform (TFA)New here? Start with a path.

1The map

In one lineTeams build services and data packages; the product repo combines them into one product version; cloud-config decides where it runs; the cluster runs it.
Service reposcode + deploy/service-chart.yaml
→
Images + service chartsbuilt by each repo's CI
ADF repodata factory source
→
ADF packagebuilt by the ADF repo's CI
Product repoproduct-deploy-<product>
→
Compositionone product version: the product chart + every component
cloud-configlandscapes/
→
Environmentnamespace, identity, secrets access, Flux
ClusterAKS
→
The product runsin namespace <environment>-<product>-<landscape>

Who owns what

RepoOwns
helm-platform-serviceThe chart every service is built from (platform-services)
helm-platform-environmentThe chart that prepares an environment (platform-environment)
Service reposCode, Dockerfile, deploy/service-chart.yaml
ADF repoThe data factory source
Product repoproduct.yaml, configurations/, pipeline.config.yaml, the CD workflows (.github/workflows/cd-*.yml)
cloud-configlandscapes/: every environment, its cluster, its values and its product versions
orchestrator-component-releaseThe command-line tool that checks and generates everything above
cloud-cicd-templatesThe shared workflows both pipelines call

2The service chart building block

In one lineOne shared Helm chart, platform-services, that every service is made of. A team fills in values; the chart does the Kubernetes work.

Published as oci://ppdevops.azurecr.io/helm/platform/platform-services (2.4.1 released; 2.5.0 adds queue-driven scaling and schedules). It is one parent chart with four parts:

platform-services/      the parent: owns the shared `global` values and the switches
├── charts/app/         long-running services: Deployment, Service, Ingress/HTTPRoute, HPA or KEDA, PDB, …
├── charts/task/        jobs such as database migrations: Job, or CronJob on a schedule
├── charts/global/      shared helpers: setting packs, host names
└── charts/keda/        shared helpers: event-driven scaling (2.5.0)

Two kinds of service

KindUse it forYou get
appAnything that keeps running: an API, a website, a queue workerDeployment, Service, optional Ingress or HTTPRoute, autoscaling (CPU-based, or KEDA on a queue or database backlog, 2.5.0), disruption budget, service account, ExternalSecret
taskSomething that runs and finishes: a migration, or a scheduled jobJob (can run before each release), or a CronJob when it has a schedule (2.5.0); service account, ExternalSecret

What the chart offers

  • Running: port, health checks, size (resources or a preset), autoscaling, schedules.
  • Settings: appSettings (nested .NET names, become A__B), env (flat names), envGroups (shared packs such as okta, identity, featureFlags, otlp).
  • Secrets: environment variable → Key Vault secret name. Never the value.
  • Ingress: a host prefix. The host becomes {prefix}{environment}.{domain}, or follows the landscape's hostPattern.

Who sets what: a service declares its settings and secrets; the product sets how it runs (port, health checks, size, Ingress); an environment may change only an agreed list (settings, size, replicas, autoscaling, on/off).

Guard rails

  • Each service's secrets become one ExternalSecret, filled from the environment's Key Vault. A name missing from the product's secret list fails the build.
  • A values schema (parent, app and task charts), valid and invalid examples, and golden-file tests run on every change to the chart.

3Preparing an environment

In one lineA second chart, platform-environment, installed once per environment. It creates the spaces, the identity and the secrets access each product needs, and tells Flux what to run.

Published as oci://ppdevops.azurecr.io/helm/platform/platform-environment (0.4.1 released; 0.5.0 adds the environment-wide identity and the configuration fingerprint). Installed by the environment pipeline as release env-<environment>-<landscape> in namespace environments.

Goes in (built by the environment pipeline)

  • environment.yaml from cloud-config, as is
  • Landscape values: the registry, each product's chart location, the pull secret
  • Flux layers: the environment's values, rendered by the orchestrator, with their fingerprint and cloud-config commit

Comes out (per enabled product)

  • Namespace <environment>-<product>-<landscape>, kept if the chart is removed
  • Service account shared-pp-workload: the environment's Azure identity id-<environment>-<landscape>, shared by its products (a product may override it). Without an identity the vault is read with the keyvault-sp secret
  • SecretStore keyvault: access to the environment's Key Vault
  • With Flux: OCIRepository, HelmRelease and three value ConfigMaps
ObjectWhat it does
OCIRepository <product>-chartWhere to find the product chart. Tracking an environment: always the newest -candidate. Pinned: that exact version.
ConfigMap <product>-values-10/20/30The environment's settings, lowest first: landscape, overrides (optional), identity
HelmRelease <product>Installs the chart with those settings. When a new version appears, Flux upgrades the product.
Annotations powerplan.com/config-hash, config-commitOn the ConfigMaps and the HelmRelease: which configuration, from which cloud-config commit, is running (0.5.0)

4Components other than Helm

In one lineA product can also ship Data Factory pipelines and Databricks bundles. They are built separately, but released at the same product version.
ComponentBuilt byArtifactIn the product
helmService repos' CIService charts helm/services/<chart>The product chart
adfThe ADF repo's CIA zip package in ACRRecorded in the composition
databricksEach bundle repo's CIA bundle zip per bundle in ACRRecorded in the composition per bundle, deployed in order

How the ADF package is built

version number→validate the factory→export ARM templates→check + zip (adf-package)→push to ACR

Pull requests build the package but push nothing.

What happens to it later

  • At compose it is not rebuilt. Its newest version and fingerprint are recorded in the composition, together with the product's ADF settings.
  • At deploy the environment's ADF settings are merged on top. The actual ARM deployment and the Databricks bundle deploy are placeholders today planned.

5The service-chart.yaml contract

In one lineOne short file per service repo is the whole agreement between a service team and the platform.

Abridged from powerplan-active-reports-service/deploy/service-chart.yaml.

apiVersion: v1
kind: ServiceChart
name: active-reports-service          # the service chart's name
platform:
  chart: platform-services
  version: ">=2.4.0, <3.0.0"          # a range: the platform chart may move within it
services:
  active-reports-service:             # one entry per deployable
    app:                              # the kind: app or task
      envGroups: [okta, identity, featureFlags, otlp]
      appSettings: {Identity: {UseIdentityProvider: "$required"}}
      env: {ASPNETCORE_URLS: "http://*:8080"}
      secrets: {ConnectionStrings__Database: tfa-reports-database-connection}
  • "$required" means the product or the environment must fill it in, or the build fails.
  • A service may not set versions, image tags, environment values or secret values. Unknown keys and anything that looks like a password are rejected.
  • A service declares its settings only. Port, health checks and size move to the product repo planned (the TFA files still carry them).

See the real files: service-chart.yaml

What the tool makes from it

In: generate-service

  • The file itself
  • The version number, from the build
  • The images the build just pushed
  • Where platform-services lives, and the commit

Out

  • A Helm chart at that version, with platform-services once per service
  • Its values: the services: block, with the built image stamped in
  • Pushed to helm/services/<name>:<version>

6The product repo

In one lineThe product repo holds configuration only. From it, the tool builds the product chart and the composition: the record of what one product version contains.
FilePurpose
product.yamlThe product and its components: helm (the product chart and its service charts), adf, databricks (named bundles, deployed in order)
configurations/helm.yamlProduct-wide settings for every landscape: global (prefixes, ingress, constants, the list of secret names) and one key per service chart
configurations/adf.yaml, databricks.yamlProduct-wide settings for the ADF component ({} when none) and for each Databricks bundle
pipeline.config.yamlThe delivery ladder: landscapes in promotion order, and their environments
.github/workflows/cd-*.ymlFive thin CD workflows, one per process: Detect, Development, Release, Promote, Retention (section 8)

See the real files: product.yaml, configurations/helm.yaml, pipeline.config.yaml

What the tool builds

CommandProduces
generate-product-chartThe product chart: every service chart pinned at its exact version, with the product settings inside
write-compositionThe composition: every component, its version and fingerprint, the source commit
approve-compositionThe approved record: every component at <version>-approved, plus who approved and when
pipeline-planFor one CD run: which landscape and environments, from pipeline.config.yaml
The composition is the source of truth of a product version. It is kept in GitHub Packages, every deploy reads it first, and the release of an approved version points to it.

Publishing in a safe order

components tagged -candidate→the composition→the product chart last

A run that stops half way leaves no half-made candidate, because Flux reacts only to the product chart. Version numbers follow the build's date and time, YYYYMM.D.HHMMSS, and the chart carries its state in its own version, for example 202610.19.183801-candidate.

Where settings come from (lowest first, later wins)

Layerhelmadf / databricks
Platform defaultsplatform-services–
Serviceservice-chart.yaml–
Productconfigurations/helm.yaml (inside the chart)configurations/<component>.yaml (in the composition)
Landscapecloud-config environments.yaml–
Environmentcloud-config <env>/<product>/helm.yaml (optional)cloud-config <env>/<product>/<component>.yaml
Identitycomputed (product, environment, landscape, tier, registry, the environment's identity); can't be overridden–

7cloud-config: the environments

In one lineOne folder per environment. What is in the folder is what should run there.
landscapes/
└── <landscape>/                 dev, qa, prod, …
    ├── environments.yaml        landscape defaults
    ├── clusters.yaml            subscription, stacks and their clusters
    └── <environment>/           app01, app02, …
        ├── environment.yaml     the environment itself
        └── <product>/
            ├── helm.yaml        this environment's product settings (optional)
            ├── adf.yaml         this environment's ADF parameters
            └── databricks.yaml  this environment's Databricks variables
FileHolds
environments.yamlLandscape name, tier, registry; the environment chart and its version range; where each product's chart lives; landscape-wide product settings
clusters.yamlTenant, subscription, active region; per stack, the cluster in each region
environment.yamlEnvironment, landscape, stack, delivery (helm or flux), the environment's identity; per product a pinned version or track: candidate; the Key Vault
<product>/*.yamlThe environment's settings for each component

render-environment turns one environment and product into ready-to-use settings and a record of which layer set each key. It also records a fingerprint of the result. It fails on a stale version, a leftover placeholder, anything that looks like a password, a setting an environment is not allowed to change, and (in strict mode) dummy values.

See the real files: environment.yaml, environments.yaml, clusters.yaml

Adding an environment

  • Create the environment's managed identity id-<environment>-<landscape> (one for all its products)
  • Add a federated credential for system:serviceaccount:<namespace>:shared-pp-workload per product namespace and cluster, each with its own name (at most 20 per identity)
  • Give it Key Vault Secrets User on the vault, and put the product's secrets in the vault
  • Copy an environment folder; change environment, landscape, stack and the identity's client ID at the top of environment.yaml
  • Open a pull request. Merging it creates the environment.

The identity steps are for workload identity (authType: WorkloadIdentity). An environment whose vault uses a service principal reads it with the keyvault-sp secret instead.

8Pipeline 1: product CD

In one lineTurns every new build or configuration change into a tested, approved product version, and promotes approved versions up the ladder. It is the only place a product version is made or promoted.

Lives in the product repo: five thin workflows in .github/workflows/, one per process, each with its own name in the Actions list. Each calls a shared template; the stage and its environments come from pipeline.config.yaml.

detect→compose→deploy to the first landscape→approve automatically when every deploy is green→release number (QA gate)→promote (landscape gate)

The first landscape comes from pipeline.config.yaml; for TFA today that is dev/app01, and it is the only one. No reviewer approves a candidate: the later landscapes are the gates.

See the real files: cd-development.yml, cd-promote.yml

The five workflows

WorkflowTriggerWhat happensStatus
CD · DetectEvery 30 minutes (from the default branch), or Run workflowIf a build or the product files moved, it starts CD · Development; otherwise the run ends green with "no change". Publishes nothing.built
CD · DevelopmentA push to main, a start by Detect, or Run workflowCompose and deploy to the first landscape, then approve automatically once every deploy is healthybuilt
CD · DevelopmentA pull requestA dry run: built and checked, nothing published or deployedbuilt
CD · ReleaseRun workflow: release number [+ version]The approved version (default: the latest) published again under the release number, after the QA reviewers. Deploying it to qa is plannedbuilt
CD · PromoteRun workflow: landscape [+ version]Promote the approved version (default: the latest) to every environment of that landscape. For now this is also how qa is reached. It does not yet check that the version went through the earlier landscapebuilt, not live
CD · RetentionWeekly, or Run workflowLists approved versions past the policy (keep the 20 newest or 60 days, plus anything in use). Reports only until switched onbuilt, not live

Promotion is not live yet

Only dev/app01 exists today. Promotion to qa and prod is not possible until greenfield IaC creates those environments: CD · Promote and CD · Release stop at their first step. When qa and prod exist: the CD GitHub App's secrets are missing from the product repo, the approval gates have no reviewers yet, and the environment pipeline it hands over to is failing. Status: where things stand.

What each step does
  • Detect compares the newest builds and the product files with the latest approved version, so unchanged inputs make nothing new.
  • Deploy reads the composition, checks the product chart's fingerprint against it, prepares the environment's settings from cloud-config, checks them, then installs (helm) or waits for Flux (flux), and checks secrets and services are up.
  • Approve re-labels everything -approved (the result must be identical), saves the approved composition and creates a GitHub pre-release. It runs only when every deploy in the first landscape succeeded, and records the automation as the approver.
  • Release runs behind the QA gate (tfa-qa-release) and re-labels the approved bytes with the release number; nothing is rebuilt.
  • Promote runs behind a gate per landscape (tfa-<landscape>-release), writes the approved version into each environment in cloud-config in one commit, then checks each environment runs it.
  • Retention never removes the newest approved version, the source of a release, or anything pinned in cloud-config.
  • Hotfix branches planned.

9Pipeline 2: environment apply

In one lineMakes every environment match its folder in cloud-config: creates it the first time, updates it after. It is the only workflow that changes environments.

Lives in cloud-config: .github/workflows/environments.yml, which runs one apply per environment that a change touched.

which environments changed?→install the environment chart→wait for secrets access→deploy each product, or wait for Flux
TriggerWhat happens
A push to landscapes/**Apply every environment the push changed: its folder, or its landscape's environments.yaml / clusters.yaml
A pull requestThe same environments, checked only
Run workflowRe-apply one environment

Failing since 26 October 2026 broken

No environment apply completes until environment chart 0.5.0 is published. Status and details

10Where the two pipelines meet

In one lineThey share one deploy step and the same environment files, and promotion connects them. Each still has one job.
PointHow
One deploy stepBoth use the same shared deploy workflow for a product in an environment
Both read cloud-configProduct CD reads it to test candidates; the environment pipeline applies it
The environment comes firstThe environment pipeline creates what Flux needs; product CD only waits for it. If the HelmRelease is missing it fails with "run cloud-config's Environments workflow for this environment first".
Candidates arrive by FluxA new candidate reaches every tracking environment without any cloud-config change
Promotion crosses overProduct CD commits the version into cloud-config; that commit starts the environment pipeline, which applies it; product CD then verifies
Approval stays in the product repoA version is approved in the product repo when it ran green in dev; cloud-config does not change for it
The compositionThe environment pipeline's deploys read the product's composition too
cloud-config is the record of what runs where, changed only by commits. The product repo is the only place a product version is made or promoted.

11Settings both pipelines need

In one lineA short list of secrets, approval gates and permissions to set up once per product.
WhereWhat
Product repo secretsDOCKER_USER, DOCKER_PASS, ORCHESTRATOR_TOKEN, CD_APP_ID + CD_APP_PRIVATE_KEY (Promote); CLOUD_CONFIG_TOKEN (optional, falls back to the workflow token; Retention needs it to read pins)
Product repo variableCD_RETENTION_DELETE: true lets Retention delete; unset means report only
Product repo GitHub Environmentstfa-<landscape> (the deploy principal: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET; no reviewers), tfa-qa-release (Release's QA gate), tfa-<landscape>-release (Promote's gate); both meant to have reviewers
cloud-config GitHub EnvironmentsOne per landscape (dev, qa, prod): the deploy principal, registry credentials, ORCHESTRATOR_TOKEN; KEYVAULT_SP_CLIENT_ID / _SECRET for environments whose vault uses a service principal
GitHub AppWrite contents and read actions on cloud-config
Package tfa-compositionRead access for cloud-config
Azure, per environmentManaged identity id-<environment>-<landscape>, a federated credential per product namespace and cluster, Key Vault Secrets User on kv-<environment>-<landscape>

This is the target setup. Today the gates have no reviewers, the CD App secrets are missing, and the deploy principal is set on the repository rather than per GitHub Environment.

12The files, explained

In one lineThe real files from the repos, as they are today, each followed by what its parts mean. Pick a file.
Service repo
Product repo
Generated by the tool
cloud-config
service-chart.yamlpowerplan-active-reports-service · deploy/service-chart.yaml
view on GitHub ↗

The whole contract between a service team and the platform. The build turns it into a service chart.

# The contract between this service repo and the platform.
#
# `orchestrator-component-release generate-service` reads this file alone to produce the
# service Helm chart. Chart version and image digests come from the build's
# images.yaml, never from here.
apiVersion: v1
kind: ServiceChart
name: active-reports-service

platform:
  chart: platform-services
  # Quoted: a bare `>` is YAML's folded-block-scalar indicator.
  version: ">=2.4.0, <3.0.0"

services:

  active-reports-service:
    app:
      # Platform env groups: Okta, identity (CORS, provider URI), feature flags, OTLP. Values come from the landscape.
      envGroups: [okta, identity, featureFlags, otlp]

      # .NET settings, nested; rendered as A__B variables. "$required": the product or environment sets it at the
      # same path. Connection strings, SyncFusionLicenseKey and AppStream__SharedSecret come from the secret below.
      appSettings:
        Logging:
          LogLevel:
            Default: Trace
        AllowedHosts: "*"
        BlobPathPrefix: taxfixedassets
        Identity:
          UseIdentityProvider: "$required"     # the inverse of Okta; set by the product

      env:
        # Was http://*:80 in the legacy chart; the hardened securityContext cannot bind below 1024.
        ASPNETCORE_URLS: "http://*:8080"

      # Synced by External Secrets Operator.
      # Secret variables: env var name -> Key Vault secret name in the product's `secrets` pool (names only).
      secrets:
        AppStream__SharedSecret: tfa-reports-appstream-secret
        ConnectionStrings__AppConfig: tfa-appconfig-connection
        ConnectionStrings__Database: tfa-reports-database-connection
        ConnectionStrings__StorageAccount: tfa-reports-storage-connection
        Sentry__Dsn: tfa-sentry-dsn
        SyncFusionLicenseKey: tfa-syncfusion-license

      containerPort: 8080

      # /api/health requires authorization, so an unauthenticated probe would never pass.
      probes:
        liveness:
          enabled: false
        readiness:
          enabled: false

      # LIMITS match what runs today exactly: 2 CPU / 8Gi (from the live
      # tfa-app override, not the stale 1 CPU / 2Gi chart default).
      #
      # REQUESTS move from 100m/4Gi to 1 CPU/4Gi. Memory is unchanged;
      # the CPU reservation rises because every preset reserves half its
      # limit. Today's 100m against a 2 CPU limit is a 20:1 burst ratio --
      # the pod is scheduled as if it needs a tenth of a core and may
      # consume twenty. Raising the request costs reserved capacity and
      # buys predictable scheduling.
      resources:
        resourcePreset: xlarge

What each part means

apiVersion, kindSays this is a ServiceChart file, so the tool knows how to read it.
nameThe service chart's name. Stays the same for the life of the repo.
platform.chart, versionWhich platform chart to build on, as a version range: newer 2.x releases are picked up automatically.
servicesOne entry per deployable. Here there is one: active-reports-service.
appThe kind of service: app keeps running; task runs once.
envGroupsShared setting packs. The values come from the landscape, not from this file.
appSettings.NET settings, nested. They become variables like Logging__LogLevel__Default. "$required" means the product or environment must fill it in.
envPlain variables with exact names.
secretsVariable name → Key Vault secret name. Each name must be in the product's list. No values here.
containerPort, probes, resourcesHow to run it: the port, health checks and a size preset. Decided to move to the product's configurations/helm.yaml: a service will declare only its settings.
product.yamlproduct-deploy-tfa · product.yaml
view on GitHub ↗

Says what the product is made of.

# The TFA product: the components that are published and deployed together, under one product version.
# Settings for each component:
# - for every group:            configurations/<component>.yaml (this repo)
# - for one group (helm only):  cloud-config landscapes/<landscape>/environments.yaml, under products.tfa
# - for one environment:        cloud-config landscapes/<landscape>/<environment>/tfa/<component>.yaml
apiVersion: v1
kind: Product
name: tfa
components:
  helm:
    chart: tfa-app                       # published as helm/products/tfa-app
    serviceCharts:                       # each chart's services come from the service repo's deploy/service-chart.yaml
      tax-fixed-assets: {}
      active-reports-service: {}
      tfa-frontend: {}
      content-deploy: {}
      tfa-zerobus: {}                    # TFA IQ data sync; off unless an environment turns it on
  adf:
    artifact: powerplan/tfa-adf-artifact          # the ARM template package
  databricks:                                     # TFA IQ bundles, deployed in this order
    spotlight-db:                                 # schemas, silver/gold pipeline, Batch Completion job
      artifact: databricks/powerplan-spotlight-db
    spotlight-api:                                # Databricks App + Genie space; uses spotlight-db's job
      artifact: databricks/powerplan-spotlight-api
# When an environment is deployed, each component is installed at the version this product version lists.

What each part means

kind: Product, nameThe product's name. It is used everywhere: namespaces, the composition package, release names.
components.helm.chartThe product chart to build (tfa-app), published as helm/products/tfa-app.
components.helm.serviceChartsThe service charts that make up the application. Their versions are not written here: compose pins the newest builds.
components.adf.artifactWhere the ADF package is published. Compose records its newest build in the composition.
components.databricksNamed Databricks bundles, each from its own repo, deployed in the order listed.
configurations/helm.yamlproduct-deploy-tfa · configurations/helm.yaml
view on GitHub ↗

Product-wide settings for the application, the same in every landscape. They are sealed into the product chart.

# Settings for the helm component (the tfa-app chart), the same in every group (landscape).
# - generate-product-chart puts this file into the chart as its default values.
# - cloud-config can override them. Order (the last one wins): this file, then
#   landscapes/<landscape>/environments.yaml (products.tfa), then <environment>/tfa/helm.yaml.
# - Layout:
#     global:            settings for the whole product, including the list of secrets
#     <service chart>:   one key per service chart, then <service>.<archetype> (app or task)

global:
  # Settings for the whole product: host name prefixes, the Ingress timeout, a few fixed values and the secrets.
  # Settings of one service are not here: the service declares them in its deploy/service-chart.yaml
  # (appSettings, env), and TFA's values for them are further down, under the service chart's key.
  prefixes:
    client: ''
    it: it-
    ars: ars-
    idp: idp-
    um: umgr-
    core: core-
    compodoc: compodoc-
  # The platform chart sets the Ingress annotations: Application Gateway when app_gateway is on, otherwise nginx.
  # This includes the class, ssl-redirect and the request timeout below. A group can override the timeout.
  ingress_timeout_seconds: 900
  ingress_extra_annotations:
    nginx:
      nginx.org/client-max-body-size: 4m
      nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
      nginx.ingress.kubernetes.io/proxy-body-size: 8m
  tls_secret_name: tls-cluster-secret
  okta_audience: api://default
  okta_enabled: true
  sentry_min_event_level: Error
  sentry_min_diagnostics_level: Error
  # The product's secrets: names of Key Vault secrets in the environment's vault (kv-<environment>), as tfa-<name>.
  # - Names only, never values.
  # - A service uses them with `secrets` in its service-chart.yaml.
  # - If a service uses a name that is not in this list, the render fails.
  secrets:
    tfa-adf-client-secret: {}
    tfa-appconfig-connection: {}
    tfa-client-sentry-dsn: {}
    tfa-content-storage-connection: {}
    tfa-database-connection: {}
    tfa-flatfile-api-key: {}
    tfa-flatfile-secret-key: {}
    tfa-genie-databricks-client-secret: {}
    tfa-identity-connection: {}
    tfa-migrations-database-connection: {}
    tfa-migrations-identity-connection: {}
    tfa-reports-appstream-secret: {}
    tfa-reports-database-connection: {}
    tfa-reports-storage-connection: {}
    tfa-sentry-dsn: {}
    tfa-storage-connection: {}
    tfa-syncfusion-license: {}
    tfa-tenantmapping-appconfig-connection: {}
    tfa-usermanagement-auth-header: {}
    tfa-zendesk-api-token: {}
    tfa-zerobus-client-id: {}
    tfa-zerobus-client-secret: {}
    tfa-zerobus-keda-sql-username: {}
    tfa-zerobus-keda-sql-password: {}

# … one key per service chart, for example:

active-reports-service:

  active-reports-service:
    app:
      ingress:
        prefix: ars
      appSettings:
        Identity:
          UseIdentityProvider: "false"   # TFA uses Okta (global okta_enabled: true)
      podSecurityContext:
        runAsNonRoot: false      # TEMPORARY, see the note above

Abridged: the global block in full, then one of the five service charts.

What each part means

global.prefixesEach service's web address prefix. With the environment and domain they make the host name.
global.ingress_…, tls_secret_nameIngress behaviour shared by every service of the product.
global.okta_…, sentry_…Product-wide constants that setting packs read.
global.secretsThe secret pool: every Key Vault secret name the product may use. Names only.
<service chart>.<service>.<kind>Per-service product settings, at the same path as in the service chart, e.g. the Ingress prefix of active-reports-service.
configurations/adf.yamlproduct-deploy-tfa · configurations/adf.yaml
view on GitHub ↗

Product-wide parameters for the ADF component. Empty today.

# Settings (ARM parameters) for the adf component, the same in every group (landscape).
# - Flat keys. Values are plain text or Key Vault secret names, never secret values.
# - An environment can override them in cloud-config: landscapes/<landscape>/<environment>/tfa/adf.yaml
#   (adf has no settings per group).
{}

What each part means

{}No product-wide parameters yet. When set, they are recorded in the composition and merged under each environment's adf.yaml.
pipeline.config.yamlproduct-deploy-tfa · pipeline.config.yaml
view on GitHub ↗

The delivery ladder: which landscapes, in which order, with which environments. The product team owns it.

# Where TFA is deployed, and in which order. The TFA product team owns this file.
# - The first group (dev) gets every new version automatically. A new version is built when this repo
#   changes, or when a service publishes a new version (checked every 30 minutes).
# - The next groups (for example qa and prod) only get a version after it passed in dev, and only when
#   someone starts "CD · Promote" by hand.
# - Each name must match a folder in the cloud-config repo: landscapes/<group>/<environment>/
# - Settings per environment:
#     {}               use the normal settings (most environments)
#     {mode: render}   only check the files, do not install
#     {strict: false}  allow placeholder values in the settings
# Planned for future:
# - Run QA tests after a deployment, input configurations would need to be specified for the tests.
# - Send Teams notifications when a deployment succeeds or fails. This file will hold the team name, users to tag etc etc.

environments:
  dev:
    app01: {}
    # test01: {}

What each part means

environmentsLandscapes in promotion order. The first (dev) is the candidate stage: every new version is deployed and approved there.
dev.app01: {}A candidate environment with the normal settings. Today it is the only one.
qa, prod (not listed)Later landscapes take approved versions only, through CD · Promote. They are left out until greenfield IaC creates those environments, so promotion is not possible today.
{mode: render}, {strict: false}Per environment: only check the files and never install, or allow placeholder values.
.github/workflows/cd-development.ymlproduct-deploy-tfa · .github/workflows/cd-development.yml
view on GitHub ↗

CD · Development: makes, deploys and approves a new version. One of five thin CD workflows; it hands the work to a shared template.

name: CD · Development

# Builds a new version of TFA and deploys it to the first group in pipeline.config.yaml (dev: app01).
# Steps (cloud-cicd-templates cd-candidate):
# 1. Check what changed: a new service build, product.yaml or configurations/.
# 2. If something changed, build a new product version (a "candidate").
# 3. Deploy it to every environment of the first group. Flux installs it; the run waits until it is done.
# 4. If every deploy is green, approve the version automatically (no reviewer). This publishes the
#    <version>-approved chart, tags and package, and a GitHub pre-release.
# - To turn off automatic approval: set `approve: false` on the candidate job.
# - Next step: "CD · Release" gives an approved version a release number.
# When it runs:
#   push to main             full run: deploy, and approve if green
#   pull request             test run: build, check and render only; nothing is published or deployed
#   cd-detect.yml or hand    full run; `reason` and `version` only say why it started
#                            (the run checks again and picks its own version number)
# Secrets: DOCKER_USER, DOCKER_PASS, ORCHESTRATOR_TOKEN, CLOUD_CONFIG_TOKEN.
# GitHub Environment: tfa-dev (the identity that deploys to dev).

run-name: >-
  ${{ format('Development tfa{0} ({1})', inputs.version && format(' {0}', inputs.version) || '',
  inputs.reason || github.event_name) }}

on:
  push:
    branches: [main]
    paths-ignore: ['**.md']
  pull_request:
    branches: [main]
    paths-ignore: ['**.md']
  workflow_dispatch:
    inputs:
      reason:
        description: Why this run (cd-detect.yml fills in what moved).
        type: string
        required: false
        default: ''
      version:
        description: The version cd-detect.yml saw (a hint for the run name; the run composes its own).
        type: string
        required: false
        default: ''
      strict:
        description: Reject dummy values in the landscape, unless an environment in pipeline.config.yaml says otherwise.
        type: boolean
        default: false

# Full runs: one at a time, the others wait.
# Pull request test runs: one per pull request; a new push cancels the old run.
concurrency:
  group: cd-development-${{ github.event_name == 'pull_request' && github.ref || 'main' }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read

jobs:
  candidate:
    uses: PowerPlanDev/cloud-cicd-templates/.github/workflows/cd-candidate.yaml@main
    permissions:
      contents: write   # create the GitHub Release of the approved version
      packages: write   # publish product versions and -approved tags
    with:
      pipeline-file: pipeline.config.yaml   # remove when cloud-cicd-templates uses this name by default
      deploy: ${{ github.event_name != 'pull_request' }}
      dry-run: ${{ github.event_name == 'pull_request' }}
      strict: ${{ inputs.strict == true }}
    secrets: inherit

What each part means

name, run-nameThe name in the Actions list, and a run title that says which version and why.
on: push, pull_requestA push to main makes a candidate when something changed; a pull request does a dry run.
workflow_dispatchRun by hand, or started by CD · Detect when something moved (it fills in reason and version).
concurrencyOne run on main at a time, queued; a newer push to a pull request cancels the older dry run.
jobs.candidate: uses cd-candidateAll the logic lives in cloud-cicd-templates: detect, compose, deploy to dev, then approve on green.
pipeline-fileThe ladder file, pipeline.config.yaml. Also the shared workflows’ default name.
deploy, dry-runDeploy on real runs; pull requests only build and check.
permissionsWrite access to GitHub Releases (the approved pre-release) and packages (compositions, -approved tags).
secrets: inheritThe repo's secrets and GitHub Environments flow into the shared workflows.
.github/workflows/cd-promote.ymlproduct-deploy-tfa · .github/workflows/cd-promote.yml
view on GitHub ↗

CD · Promote: moves a later landscape to an approved version, behind that landscape's reviewers.

name: CD · Promote

# Started by hand. Moves a later group (landscape) in pipeline.config.yaml, qa or prod, to an approved version.
# Steps (cloud-cicd-templates cd-promote):
# 1. A reviewer approves in the GitHub Environment tfa-<landscape>-release.
# 2. One cloud-config commit sets <version>-approved for every environment of the group.
# 3. cloud-config's Environments workflow applies it.
# 4. One check job per environment confirms the deploy (runs as tfa-<landscape>).
# - `version` empty: the latest approved version.
# - Placeholder values are not allowed, unless the environment sets `strict: false` in pipeline.config.yaml.
# - For now qa is promoted here. Later "CD · Release" will deploy releases to qa, and this workflow will
#   be used for prod only, with release numbers. (This needs release-number support in orchestrator-component-release.)
# Secrets: DOCKER_USER, DOCKER_PASS, ORCHESTRATOR_TOKEN, CLOUD_CONFIG_TOKEN,
#   CD_APP_ID and CD_APP_PRIVATE_KEY (the GitHub App that writes to cloud-config).
# GitHub Environments: tfa-<landscape>-release (reviewers), tfa-<landscape> (the identity that deploys).

run-name: Promote tfa ${{ inputs.version || 'latest approved' }} → ${{ inputs.landscape }} by @${{ github.actor }}

on:
  workflow_dispatch:
    inputs:
      landscape:
        description: The landscape of pipeline.config.yaml to promote to.
        type: choice
        required: true
        options: [qa, prod]
      version:
        description: The approved version, e.g. 202610.19.183801 (or -approved). Empty = the latest approved release.
        type: string
        required: false
        default: ''

# One promotion per group at a time; the others wait.
concurrency:
  group: cd-promote-${{ inputs.landscape }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  promote:
    uses: PowerPlanDev/cloud-cicd-templates/.github/workflows/cd-promote.yaml@main
    permissions:
      contents: read
      packages: read
    with:
      pipeline-file: pipeline.config.yaml   # remove when cloud-cicd-templates uses this name by default
      landscape: ${{ inputs.landscape }}
      version: ${{ inputs.version }}
    secrets: inherit

What each part means

workflow_dispatch: landscape, versionRun by hand: pick qa or prod, and a version (empty: the latest approved). Not possible today: there is no qa or prod environment yet.
concurrencyOne promotion per landscape at a time; later runs queue.
jobs.promote: uses cd-promoteGate, write the version into cloud-config with the GitHub App, follow the environment pipeline, verify.
permissionsRead only: the cloud-config write uses the GitHub App's token, not this workflow's.
secrets: inheritIncludes CD_APP_ID and CD_APP_PRIVATE_KEY, the App that writes to cloud-config.
a generated service charthelm-platform-service · tests/golden/inputs/service-charts/active-reports-service/Chart.yaml
view on GitHub ↗

What generate-service makes from service-chart.yaml: a chart that depends on the platform chart once per service.

apiVersion: v2
name: active-reports-service
description: 'GENERATED by `release-component generate-service` from deploy/service-chart.yaml.
  Do not edit: the next build overwrites it.'
type: application
version: 202601.1.1
appVersion: 202601.1.1
dependencies:
- name: platform-services
  alias: active-reports-service
  version: '*'
  repository: file://../../../../..
  condition: active-reports-service.app.enabled
annotations:
  powerplan.com/source-commit: '0000000000000000000000000000000000000000'

This copy is a golden-test input: it uses a placeholder version and a local path. A real build uses the CalVer and oci://ppdevops.azurecr.io/helm/platform.

What each part means

name, versionThe service chart's name, at the build's version.
dependencies: platform-servicesThe platform chart, once per service.
aliasThe service's name, so each service gets its own copy of the platform chart's values.
condition<service>.<kind>.enabled: only the kind the service uses is switched on.
annotationsThe source commit, so every chart can be traced back to its code.
a generated product charthelm-platform-service · tests/golden/inputs/product/Chart.yaml
view on GitHub ↗

What generate-product-chart makes: an umbrella chart with no templates, just the service charts.

apiVersion: v2
name: tfa-app
description: GENERATED by `release-component generate-product-chart` from product.yaml
  and configurations/. Do not edit.
type: application
version: 202601.1.1
appVersion: 202601.1.1
dependencies:
- name: tax-fixed-assets
  version: '*'
  repository: file://../service-charts/tax-fixed-assets
- name: active-reports-service
  version: '*'
  repository: file://../service-charts/active-reports-service
- name: tfa-frontend
  version: '*'
  repository: file://../service-charts/tfa-frontend
- name: content-deploy
  version: '*'
  repository: file://../service-charts/content-deploy
annotations:
  powerplan.com/source-commit: '0000000000000000000000000000000000000000'

A golden-test input: placeholder version, local paths and '*' ranges instead of exact pins.

What each part means

nameFrom product.yaml (components.helm.chart).
versionIn a real compose: <version>-candidate, later repackaged as -approved.
dependenciesEach service chart, pinned at its exact version in a real compose.
annotationsThe product repo commit it was built from.
composition.yamldesign example · ideas/product/composition.example.yaml

The record of exactly what one product version contains. Every deploy reads it first.

# EXAMPLE of a composition: what one product version is made of. It lives only in GitHub Packages:
#   compose   pushes  oci://ghcr.io/powerplandev/tfa-composition:<version>-candidate
#   approval  pushes  oci://ghcr.io/powerplandev/tfa-composition:<version>-approved (approval filled in) and writes that
#             address and digest into the GitHub Release notes -- the release points at the composition, no copy
# The package is linked to product-deploy-tfa (pushed from it, annotation org.opencontainers.image.source) and inherits
# its access; cloud-config is granted read on it.
# Every deploy run (production included) pulls the composition first and deploys exactly the components, versions and
# digests it names. Detect follows the latest approved release to its composition and compares with the newest builds.
apiVersion: v1
kind: Composition
product: tfa
version: 202610.18.1
state: candidate                          # candidate when composed; approved after the deploy ladder (approve job)
source:
  repository: PowerPlanDev/product-deploy-tfa
  commit: 0123456789abcdef0123456789abcdef01234567
composedAt: 2026-10-18T09:30:00Z
components:
  helm:
    chart: helm/products/tfa-app
    version: 202610.18.1-candidate        # the chart's own version carries the state (Flux: tag must equal it)
    digest: sha256:...
    serviceCharts:                        # what the product chart pins
      tax-fixed-assets: 202610.17.3
      active-reports-service: 202610.15.2
      tfa-frontend: 202610.18.1
      content-deploy: 202610.12.4
  adf:
    artifact: powerplan/tfa-adf-artifact
    version: 202610.18.1-candidate
    digest: sha256:...
    built: 202610.16.2                    # the ADF build this product version carries
  databricks:
    artifact: databricks/tfa
    version: 202610.18.1-candidate
    digest: sha256:...
    built: 202610.18.1
approval: {}                              # filled by approve: approvedAt, approvedBy, run

The format example from the design. Real compositions are published to ghcr.io/powerplandev/tfa-composition.

What each part means

version, stateThe product version and whether it is a candidate or approved.
sourceThe product repo and commit it was composed from.
components.helmThe product chart's version and fingerprint, and the exact service chart versions inside it.
components.adf / databricksEach artifact's product version, fingerprint, and which build it carries.
approvalFilled in at approval: who, when, which run.
environments.yamlcloud-config · landscapes/dev/environments.yaml
view on GitHub ↗

Landscape defaults: true for every environment in dev.

# Landscape defaults: apply to every environment in this landscape.
# No secrets, no tokens. Secrets live in the environment's Key Vault, named by the `secrets` pool in
# product-deploy-tfa/configurations/global.yaml.
landscape: dev
tier: dev
registry: ppdevops.azurecr.io
# What deploy-environment installs here (charts in `registry`). environmentChart.version is exact or a range,
# resolved to the newest match at deploy time; narrow it (or pin) in the landscapes after this one.
platform:
  environmentChart:
    name: platform-environment
    repository: helm/platform
    version: ">=0.1.0, <1.0.0"
  productCharts:
    tfa:
      chart: tfa-app
      repository: helm/products
  # Flux delivery pulls the product chart with the same secret the images use (the cluster is not attached to ACR).
  chartPullSecret: acr-pull-secret
products:
  tfa:
    domain_name: dev.az.powerplancloud.com
    app_gateway: true
    frontdoor_enabled: false
    okta_domain: https://powerplan-dev.oktapreview.com/
    platform_user_management_api_rest_url: https://umgrnew-uat.dev.az.powerplancloud.com/
    platform_user_management_api_grpc_enabled: false
    platform_user_management_api_grpc_url: https://dummy.invalid
    azure_ad_tenant_id: 00000000-0000-0000-0000-000000000000
    azure_ad_client_id: 00000000-0000-0000-0000-000000000000
    db_server: <redacted>
    db_username: dummy-db-user
    sql_command_timeout: 90
    long_running_sql_command_timeout: 3600
    graphql_query_timeout: 300
    genie_databricks_base_url: https://dummy.invalid
    genie_databricks_client_id: 00000000-0000-0000-0000-000000000000
    genie_databricks_workspace_host: dummy.invalid
    feature_flag_cache_seconds: 1800
    # Every service pulls with the docker-registry secret `acr-pull-secret`, created by hand in the namespace
    # (the cluster is not attached to the registry yet). Other landscapes attach the registry and need none.
    services:
      tax-fixed-assets:
        tax-fixed-assets-api:
          app:
            imagePullSecrets:
            - name: acr-pull-secret
            resources:
              resourcePreset: micro
            replicaCount: 1
            appSettings:
              Azure:
                TenantId: <redacted>
                SubscriptionId: <redacted>
              DisableInServiceMigrations: true
        tfa-migrations:
          task:
            imagePullSecrets:
            - name: acr-pull-secret
            resources:
              resourcePreset: small
        redis:
          app:
            imagePullSecrets:
            - name: acr-pull-secret
      tfa-frontend:
        tfa-client:
          app:
            imagePullSecrets:
            - name: acr-pull-secret
            resources:
              resourcePreset: nano
            env:
              USER_MANAGEMENT_API_URL: https://umgrnew-uat.dev.az.powerplancloud.com/api
              PORTAL_URL: https://dummy.invalid
              OKTA_ISSUER: https://powerplan-dev.oktapreview.com/oauth2/default
              ZENDESK_WIDGET_KEY: dummy-zendesk-widget
              FLATFILE_ENVIRONMENT_ID: dummy-flatfile-env
              FLATFILE_PUBLISHABLE_KEY: dummy-flatfile-pub
        compodoc:
          app:
            imagePullSecrets:
            - name: acr-pull-secret
            enabled: true
        flatfile-listeners:
          task:
            imagePullSecrets:
            - name: acr-pull-secret
            enabled: true
      active-reports-service:
        active-reports-service:
          app:
            imagePullSecrets:
            - name: acr-pull-secret
            resources:
              resourcePreset: micro
      content-deploy:
        content-deploy:
          task:
            imagePullSecrets:
            - name: acr-pull-secret
            resources:
              resourcePreset: micro

IDs and server names are redacted. The header comment still names global.yaml; the pool is now in configurations/helm.yaml.

What each part means

landscape, tier, registryWhich landscape this is, its tier, and the registry charts and images come from.
platform.environmentChartThe environment chart and the version range to install.
platform.productChartsWhere each product's chart lives, so Flux knows what to pull.
platform.chartPullSecretThe secret used to pull charts, while the cluster is not attached to the registry.
products.tfa (flat keys)Landscape settings for the product. Flat keys become global.<key>: domain, login system, timeouts.
products.tfa.servicesPer-service settings for this landscape, at the same path as the service chart: sizes, pull secrets, extra variables.
clusters.yamlcloud-config · landscapes/dev/clusters.yaml
view on GitHub ↗

Where the landscape's clusters are. Environments refer to a stack, never to a cluster name.

# The subscription and clusters of the dev landscape: what `az aks get-credentials` and `helm upgrade` need.
# One subscription per landscape. An environment names a stack in its environment.yaml; orchestrator-component-release checks the
# stack is listed here and deploys to that stack's cluster in `activeRegion`. Never put a cluster name in an
# environment file. Names follow the naming standard (aks-<landscape>, rg-<landscape>-platform).
# Tenant and subscription ids are dummy (all zeros) until the real ones are known.
tenant: "powerplan"
tenantId: <redacted>
subscription: dev
subscriptionId: <redacted>
managementGroup: mg-development
activeRegion: primary
stacks:
  dev-v1:
    primary:
      name: pp-aks-cloud-eastus-main3
      resourceGroup: pp-rg-cloud-eastus-aks3
      tier: free
      kubelogin: azurecli
      gateway: {name: dev-lower-gateway, namespace: env-system}

Tenant and subscription IDs are redacted.

What each part means

tenant, subscriptionThe Azure tenant and subscription of the landscape.
activeRegionWhich region's cluster to deploy to (primary, or a DR region).
stacks.<stack>.<region>The cluster for that stack and region: name, resource group, tier, how to log in, the gateway.
environment.yamlcloud-config · landscapes/dev/app01/environment.yaml
view on GitHub ↗

The environment itself. Also the main input of the environment chart.

# Environment manifest: identity, the product pins and environment-wide overrides.
# Also the platform-environment chart's values (namespaces, Key Vault SecretStores, and with delivery: flux the
# OCIRepository + HelmRelease per product), with a second file deploy-environment derives from environments.yaml.
# Move to environments namespace once ready
environment: app01
landscape: dev
stack: dev-v1
delivery: flux
# The environment's managed identity (target name id-app01-dev; testing uses id-app01-tfa-dev): every product
# namespace's ServiceAccount shared-pp-workload. One federated credential per product namespace and cluster, Key Vault
# Secrets User on the vault below. A product may override it with products.<product>.workloadIdentityClientId.
workloadIdentityClientId: <redacted>
products:
  tfa:
    enabled: true
    track: candidate
# The environment's Key Vault (name defaults to kv-<environment>-<landscape>). WorkloadIdentity: each
# product's SecretStore reads it as the environment's identity. No secret values here.
keyVault:
  name: kv-app01-prod          # override for now: the vault that exists; remove once kv-app01-dev is created
  authType: WorkloadIdentity
  tenantId: <redacted>

Client and tenant IDs are redacted.

What each part means

environment, landscapeMust match the folder names.
stackWhich stack in clusters.yaml it runs on.
deliveryflux (Flux installs) or helm (the pipeline installs).
products.tfa.track: candidateAlways follow the newest candidate. A pinned environment has version: <version>-approved instead.
workloadIdentityClientIdThe environment's Azure identity, used by every product namespace (the shared-pp-workload service account). A product may override it.
keyVaultThe environment's vault and how to reach it (workload identity or a service principal).
<product>/helm.yamlcloud-config · landscapes/dev/app01/tfa/helm.yaml
view on GitHub ↗

This environment's settings for the product chart. Optional; becomes the 20-overrides layer.

# Values for the TFA product chart, this environment only. The inputs are the same set in every landscape.
# Everything else derives from the environment name or comes from the landscape and product tiers.
okta_client_id: <redacted>
storage_account: <redacted>
okta_integration_app_client_id: dummy-okta-integration
services:
  tax-fixed-assets:
    tax-fixed-assets-api:
      app:
        appSettings:
          Azure:
            ClientId: <redacted>
            AppInfo:
              TFAResourceGroup: pp-rg-cloud-tfadevops-tfa
              TFAFactoryName: pp-adf-cloud-tfadevops-tfa
    tfa-migrations:
      task:
        appSettings:
          UserTokens:
            TfaIqEnabled: false
            ZerobusServicePrincipal: dummy-zerobus
  tfa-frontend:
    tfa-client:
      app:
        env:
          GAINSIGHT_TAG_KEY: dummy-gainsight
          OKTA_CLIENT_ID: <redacted>
          ENVIRONMENT_TAG: app01
    flatfile-listeners:
      task:
        enabled: false    # on for the dev landscape; not run in app01

IDs and the storage account name are redacted.

What each part means

flat keysEnvironment-only values such as the login client ID.
services.<chart>.<service>.<kind>Per-service values at the same path, e.g. filling a "$required" setting or switching an optional service off.
<product>/adf.yamlcloud-config · landscapes/dev/app01/tfa/adf.yaml
view on GitHub ↗

This environment's ADF parameters. Empty today.

# ARM override parameters for the TFA data factory, this environment only.
# Flat keys; literals or AKV secret names, never secret values. Empty when the environment has none.
{}

What each part means

{}Nothing to override yet. Values here win over configurations/adf.yaml.

Copied from the product repo's main branch on 28 October 2026, and from the local working copies of the other repos on 26 October 2026. IDs, client IDs and server or storage names are replaced with <redacted>.