Stand-up Workflows — Infra 0–6, Identities, and the Port Front Door
The Deployment Order page
explains what applies in what order and why. This page documents the
machinery that runs it: six GitHub Actions workflows in eshars-ops
(.github/workflows/), chained by an orchestrator and fronted by a Port
self-service action. A from-zero tier stand-up is one dispatch.
Facts on this page are current as of eshars-ops develop (July 2026 —
PRs #75, #79, #81, #85, #86, #92).
The six layers + the orchestrator
| Workflow | Name | Runner | Identity | Applies |
|---|---|---|---|---|
infra-standup.yml | Infra: 0 - Stand-up (orchestrator) | — | — | Chains layers 1→6 via workflow_call + needs:; inputs: tier, mode (plan/apply), from_layer (resume point), include_environments / environments, vm_roles, allow_replace, confirm_prod |
platform-connectivity.yml | Infra: 1 - Platform Connectivity | GitHub-hosted | platform SP | cpc (hub VNet + VPN gateway, 90-min timeout) → dns-zones → dns-fwd |
foundation.yml | Infra: 2 - Foundation | GitHub-hosted | platform SP | wn (spoke network) → wc (workload-core) |
ops.yml | Infra: 3 - Ops | GitHub-hosted | platform SP | The ops VM — which becomes the self-hosted runner the later layers need |
secrets.yml | Infra: 4 - Secrets | self-hosted [self-hosted, eshars, {tier}] | workload SP | wc-secrets (in-spoke data-plane writes to the RV KV) |
tier-vms.yml | Infra: 5 - VMs | self-hosted | workload SP | Matrix over the VM roles (job-scheduler, report-server, rns, successed, db-api, tmhp-sftp, eshars-sftp); roles with no tfvars for the tier are skipped (rns/tmhp-sftp are prod-only) |
environment-workload.yml | Infra: 6 - Environment Workload | self-hosted | workload SP | workload-env for one environment (dev/qa/regression/uat/training/prod) + the full post-apply wiring |
Every layer also supports standalone workflow_dispatch, so you can run just
one (e.g. re-apply Infra: 5 for a single VM role) without the orchestrator.
The bootstrap chicken-and-egg, resolved
Layers 1–3 run on GitHub-hosted runners because at from-zero there is no
self-hosted runner yet — Infra 3 creates it (the ops VM registers org-level
runners labeled eshars,{tier},windows). Layers 4–6 target
[self-hosted, eshars, {tier}] and simply queue until that runner
registers (~15–20 minutes from zero). Nothing needs babysitting; the chain is
self-resolving.
Layers 4–6 must be self-hosted: they perform data-plane operations against private-endpoint-only resources (the RV KV writes, env-KV secret writes, SQL logins), which only succeed from inside the spoke.
The identity split (foundation vs workload)
Two OIDC service principals, two GitHub Environments (eshars-ops #79):
| Platform SP | Workload deploy SP | |
|---|---|---|
| App name | github-eshars-platform | github-eshars-deploy |
| GitHub variable | vars.AZURE_PLATFORM_CLIENT_ID | vars.AZURE_CLIENT_ID |
| GitHub Environment | platform | ops |
| Runs | Infra 1–3 (cpc, dns-zones, dns-fwd, wn, wc, ops VM) | Infra 4–6, runbook deploys, Port actions |
| Federated credentials | ref:refs/heads/develop (preflights) + environment:platform (applies) | same pattern with environment:ops |
Why the split: the foundation layers create resource groups, which is only grantable at subscription scope — so today both SPs hold subscription Contributor + User Access Administrator. The boundary is therefore identity and approval isolation (separate credentials, separate GitHub Environment gates), explicitly not yet resource-scope isolation. Documented hardening follow-up: ABAC conditions or per-tier subscriptions.
Where scoping is real: workload-core mints the workload SP’s day-to-day
grants — Contributor + UAA on five tier RGs only (security, data,
storage, web, monitoring), Reader on the ops RG (it must not be
able to rewrite its own executor), Contributor on the spoke network RG, and
Storage Blob Data Contributor on the BACPAC patch store. compute, ops, and
backup writes are deliberately excluded.
The app CD (HISD repo) is different again: it authenticates as the ops
VM’s managed identity (az login --identity) — no SP at all. Three
identities, three blast radii.
One directory-plane wrinkle: wc adds the tier automation account’s MI to the
sg-eshars-db-{tier}-dba Entra group, which requires the platform SP to be an
owner of that group — a one-time per-tier bootstrap
(az ad group owner add, documented in the foundation.yml header).
The shared executor and its guards
Layers 1–5 all run scripts/ci/apply-tf-layer.sh, which does
tofu init -reconfigure → plan -out=tfplan.bin → prints the plan tail →
destroy-guard → apply -auto-approve tfplan.bin (the exact planned file —
no plan/apply drift window).
- Destroy-guard: parses
tofu show -jsonand hard-fails if the plan deletes any of ~21 load-bearingazurerm_*types (SQL servers/DBs, VNets, Key Vaults, …). Plan-only runs are ungated; applies can’t silently destroy. ALLOW_REPLACE(#85): a deliberate destroy/replace is unblocked by passing the exact resource address(es); only addresses that are both flagged by the guard and explicitly named pass. Unmatched entries warn.- No secret inputs: there are no
TF_VAR_*secrets anywhere in the chain (#81) — passwords are Terraform-generated into Key Vaults; the two operator-supplied values (SqlServerAdminLogin,TeamsWebhookUrl) are read from the scaffold KV at run time by Infra 6. - Prod applies additionally require
confirm_prod=trueon the orchestrator. - Layer 6 has its own guard: it refuses any plan that deletes
azurerm_mssql_server/_database/_elasticpool, then runs the post-apply wiring (provision-db.sh,copy-kv-secrets.sh, app restart,setup-db-ssrs-reader.sh) and the two gates (DBs online,verify-env-secrets.sh).
Concurrency groups are per tier/env with cancel-in-progress: false, so two
dispatches serialize instead of corrupting state.
Approvals
The platform / ops GitHub Environments carry the OIDC subject for the
apply jobs. Required-reviewer protection on environments is an
Enterprise-plan feature for private repos, so on the current plan the gates
are: manual dispatch + plan-first mode + the destroy-guard (+ confirm_prod).
Resume and failure recovery
The orchestrator’s summary job prints each layer’s result and the
from_layer value to resume with — re-dispatch with from_layer=<n> and
completed layers are skipped. For root-causing a failed run, see the
eshars:cd-triage tooling in the ops repo.
The Port front door
.port/actions/ in eshars-ops holds the Port action definitions
(version-controlled JSON, deployed by deploy-port-actions.yml on push to
develop). Current self-service actions:
| Port action | Backing workflow | What it does |
|---|---|---|
infra_standup | infra-standup.yml | Tier stand-up / resume, plan or apply |
| Restore Database | port-restore-db.yml | Self-service BACPAC restore — see Database Restore & Seed (v2 runbooks) |
| Seed Dev Data | port-seed-dev-data.yml | Dev-only synthetic or QA-slice seeding — same page |
Related
- Deployment Order — what the layers apply and why the order matters
- Workload-env Stand-up runbook — the manual path for one environment (what Infra 6 automates)
- Configuration & Secrets — where every secret comes from
- GitHub Runner on the Ops VM — how the self-hosted runner registers
- Database Restore & Seed (v2 runbooks)