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.
1The map
deploy/service-chart.yamlproduct-deploy-<product>landscapes/<environment>-<product>-<landscape>Who owns what
| Repo | Owns |
|---|---|
helm-platform-service | The chart every service is built from (platform-services) |
helm-platform-environment | The chart that prepares an environment (platform-environment) |
| Service repos | Code, Dockerfile, deploy/service-chart.yaml |
| ADF repo | The data factory source |
| Product repo | product.yaml, configurations/, pipeline.config.yaml, the CD workflows (.github/workflows/cd-*.yml) |
cloud-config | landscapes/: every environment, its cluster, its values and its product versions |
orchestrator-component-release | The command-line tool that checks and generates everything above |
cloud-cicd-templates | The shared workflows both pipelines call |
2The service chart building block
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
| Kind | Use it for | You get |
|---|---|---|
| app | Anything that keeps running: an API, a website, a queue worker | Deployment, Service, optional Ingress or HTTPRoute, autoscaling (CPU-based, or KEDA on a queue or database backlog, 2.5.0), disruption budget, service account, ExternalSecret |
| task | Something that runs and finishes: a migration, or a scheduled job | Job (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 (
resourcesor a preset), autoscaling, schedules. - Settings:
appSettings(nested .NET names, becomeA__B),env(flat names),envGroups(shared packs such asokta,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'shostPattern.
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
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.yamlfrom 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 identityid-<environment>-<landscape>, shared by its products (a product may override it). Without an identity the vault is read with thekeyvault-spsecret - SecretStore
keyvault: access to the environment's Key Vault - With Flux: OCIRepository, HelmRelease and three value ConfigMaps
| Object | What it does |
|---|---|
OCIRepository <product>-chart | Where to find the product chart. Tracking an environment: always the newest -candidate. Pinned: that exact version. |
ConfigMap <product>-values-10/20/30 | The 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-commit | On the ConfigMaps and the HelmRelease: which configuration, from which cloud-config commit, is running (0.5.0) |
4Components other than Helm
| Component | Built by | Artifact | In the product |
|---|---|---|---|
| helm | Service repos' CI | Service charts helm/services/<chart> | The product chart |
| adf | The ADF repo's CI | A zip package in ACR | Recorded in the composition |
| databricks | Each bundle repo's CI | A bundle zip per bundle in ACR | Recorded in the composition per bundle, deployed in order |
How the ADF package is built
adf-package)→push to ACRPull 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
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-serviceslives, and the commit
Out
- A Helm chart at that version, with
platform-servicesonce per service - Its values: the
services:block, with the built image stamped in - Pushed to
helm/services/<name>:<version>
6The product repo
| File | Purpose |
|---|---|
product.yaml | The product and its components: helm (the product chart and its service charts), adf, databricks (named bundles, deployed in order) |
configurations/helm.yaml | Product-wide settings for every landscape: global (prefixes, ingress, constants, the list of secret names) and one key per service chart |
configurations/adf.yaml, databricks.yaml | Product-wide settings for the ADF component ({} when none) and for each Databricks bundle |
pipeline.config.yaml | The delivery ladder: landscapes in promotion order, and their environments |
.github/workflows/cd-*.yml | Five 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
| Command | Produces |
|---|---|
generate-product-chart | The product chart: every service chart pinned at its exact version, with the product settings inside |
write-composition | The composition: every component, its version and fingerprint, the source commit |
approve-composition | The approved record: every component at <version>-approved, plus who approved and when |
pipeline-plan | For one CD run: which landscape and environments, from pipeline.config.yaml |
Publishing in a safe order
-candidate→the composition→the product chart lastA 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)
| Layer | helm | adf / databricks |
|---|---|---|
| Platform defaults | platform-services | – |
| Service | service-chart.yaml | – |
| Product | configurations/helm.yaml (inside the chart) | configurations/<component>.yaml (in the composition) |
| Landscape | cloud-config environments.yaml | – |
| Environment | cloud-config <env>/<product>/helm.yaml (optional) | cloud-config <env>/<product>/<component>.yaml |
| Identity | computed (product, environment, landscape, tier, registry, the environment's identity); can't be overridden | – |
7cloud-config: the environments
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
| File | Holds |
|---|---|
environments.yaml | Landscape name, tier, registry; the environment chart and its version range; where each product's chart lives; landscape-wide product settings |
clusters.yaml | Tenant, subscription, active region; per stack, the cluster in each region |
environment.yaml | Environment, landscape, stack, delivery (helm or flux), the environment's identity; per product a pinned version or track: candidate; the Key Vault |
<product>/*.yaml | The 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-workloadper product namespace and cluster, each with its own name (at most 20 per identity) - Give it
Key Vault Secrets Useron the vault, and put the product's secrets in the vault - Copy an environment folder; change
environment,landscape,stackand the identity's client ID at the top ofenvironment.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
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.
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
| Workflow | Trigger | What happens | Status |
|---|---|---|---|
| CD · Detect | Every 30 minutes (from the default branch), or Run workflow | If a build or the product files moved, it starts CD · Development; otherwise the run ends green with "no change". Publishes nothing. | built |
| CD · Development | A push to main, a start by Detect, or Run workflow | Compose and deploy to the first landscape, then approve automatically once every deploy is healthy | built |
| CD · Development | A pull request | A dry run: built and checked, nothing published or deployed | built |
| CD · Release | Run 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 planned | built |
| CD · Promote | Run 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 landscape | built, not live |
| CD · Retention | Weekly, or Run workflow | Lists approved versions past the policy (keep the 20 newest or 60 days, plus anything in use). Reports only until switched on | built, 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
Lives in cloud-config: .github/workflows/environments.yml, which runs one apply per environment that a change touched.
| Trigger | What happens |
|---|---|
A push to landscapes/** | Apply every environment the push changed: its folder, or its landscape's environments.yaml / clusters.yaml |
| A pull request | The same environments, checked only |
| Run workflow | Re-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
| Point | How |
|---|---|
| One deploy step | Both use the same shared deploy workflow for a product in an environment |
| Both read cloud-config | Product CD reads it to test candidates; the environment pipeline applies it |
| The environment comes first | The 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 Flux | A new candidate reaches every tracking environment without any cloud-config change |
| Promotion crosses over | Product 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 repo | A version is approved in the product repo when it ran green in dev; cloud-config does not change for it |
| The composition | The environment pipeline's deploys read the product's composition too |
11Settings both pipelines need
| Where | What |
|---|---|
| Product repo secrets | DOCKER_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 variable | CD_RETENTION_DELETE: true lets Retention delete; unset means report only |
| Product repo GitHub Environments | tfa-<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 Environments | One 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 App | Write contents and read actions on cloud-config |
Package tfa-composition | Read access for cloud-config |
| Azure, per environment | Managed 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
deploy/service-chart.yamlThe 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, kind | Says this is a ServiceChart file, so the tool knows how to read it. |
name | The service chart's name. Stays the same for the life of the repo. |
platform.chart, version | Which platform chart to build on, as a version range: newer 2.x releases are picked up automatically. |
services | One entry per deployable. Here there is one: active-reports-service. |
app | The kind of service: app keeps running; task runs once. |
envGroups | Shared 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. |
env | Plain variables with exact names. |
secrets | Variable name → Key Vault secret name. Each name must be in the product's list. No values here. |
containerPort, probes, resources | How 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.yamlSays 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, name | The product's name. It is used everywhere: namespaces, the composition package, release names. |
components.helm.chart | The product chart to build (tfa-app), published as helm/products/tfa-app. |
components.helm.serviceCharts | The service charts that make up the application. Their versions are not written here: compose pins the newest builds. |
components.adf.artifact | Where the ADF package is published. Compose records its newest build in the composition. |
components.databricks | Named Databricks bundles, each from its own repo, deployed in the order listed. |
configurations/helm.yamlProduct-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.prefixes | Each service's web address prefix. With the environment and domain they make the host name. |
global.ingress_…, tls_secret_name | Ingress behaviour shared by every service of the product. |
global.okta_…, sentry_… | Product-wide constants that setting packs read. |
global.secrets | The 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-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.yamlThe 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
environments | Landscapes 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.ymlCD · 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-name | The name in the Actions list, and a run title that says which version and why. |
on: push, pull_request | A push to main makes a candidate when something changed; a pull request does a dry run. |
workflow_dispatch | Run by hand, or started by CD · Detect when something moved (it fills in reason and version). |
concurrency | One run on main at a time, queued; a newer push to a pull request cancels the older dry run. |
jobs.candidate: uses cd-candidate | All the logic lives in cloud-cicd-templates: detect, compose, deploy to dev, then approve on green. |
pipeline-file | The ladder file, pipeline.config.yaml. Also the shared workflows’ default name. |
deploy, dry-run | Deploy on real runs; pull requests only build and check. |
permissions | Write access to GitHub Releases (the approved pre-release) and packages (compositions, -approved tags). |
secrets: inherit | The repo's secrets and GitHub Environments flow into the shared workflows. |
.github/workflows/cd-promote.ymlCD · 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, version | Run by hand: pick qa or prod, and a version (empty: the latest approved). Not possible today: there is no qa or prod environment yet. |
concurrency | One promotion per landscape at a time; later runs queue. |
jobs.promote: uses cd-promote | Gate, write the version into cloud-config with the GitHub App, follow the environment pipeline, verify. |
permissions | Read only: the cloud-config write uses the GitHub App's token, not this workflow's. |
secrets: inherit | Includes CD_APP_ID and CD_APP_PRIVATE_KEY, the App that writes to cloud-config. |
tests/golden/inputs/service-charts/active-reports-service/Chart.yamlWhat 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, version | The service chart's name, at the build's version. |
dependencies: platform-services | The platform chart, once per service. |
alias | The 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. |
annotations | The source commit, so every chart can be traced back to its code. |
tests/golden/inputs/product/Chart.yamlWhat 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
name | From product.yaml (components.helm.chart). |
version | In a real compose: <version>-candidate, later repackaged as -approved. |
dependencies | Each service chart, pinned at its exact version in a real compose. |
annotations | The product repo commit it was built from. |
ideas/product/composition.example.yamlThe 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, state | The product version and whether it is a candidate or approved. |
source | The product repo and commit it was composed from. |
components.helm | The product chart's version and fingerprint, and the exact service chart versions inside it. |
components.adf / databricks | Each artifact's product version, fingerprint, and which build it carries. |
approval | Filled in at approval: who, when, which run. |
landscapes/dev/environments.yamlLandscape 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, registry | Which landscape this is, its tier, and the registry charts and images come from. |
platform.environmentChart | The environment chart and the version range to install. |
platform.productCharts | Where each product's chart lives, so Flux knows what to pull. |
platform.chartPullSecret | The 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.services | Per-service settings for this landscape, at the same path as the service chart: sizes, pull secrets, extra variables. |
landscapes/dev/clusters.yamlWhere 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, subscription | The Azure tenant and subscription of the landscape. |
activeRegion | Which 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. |
landscapes/dev/app01/environment.yamlThe 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, landscape | Must match the folder names. |
stack | Which stack in clusters.yaml it runs on. |
delivery | flux (Flux installs) or helm (the pipeline installs). |
products.tfa.track: candidate | Always follow the newest candidate. A pinned environment has version: <version>-approved instead. |
workloadIdentityClientId | The environment's Azure identity, used by every product namespace (the shared-pp-workload service account). A product may override it. |
keyVault | The environment's vault and how to reach it (workload identity or a service principal). |
landscapes/dev/app01/tfa/helm.yamlThis 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 keys | Environment-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. |
landscapes/dev/app01/tfa/adf.yamlThis 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>.