Skip to content

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

WorkflowNameRunnerIdentityApplies
infra-standup.ymlInfra: 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.ymlInfra: 1 - Platform ConnectivityGitHub-hostedplatform SPcpc (hub VNet + VPN gateway, 90-min timeout) → dns-zonesdns-fwd
foundation.ymlInfra: 2 - FoundationGitHub-hostedplatform SPwn (spoke network) → wc (workload-core)
ops.ymlInfra: 3 - OpsGitHub-hostedplatform SPThe ops VM — which becomes the self-hosted runner the later layers need
secrets.ymlInfra: 4 - Secretsself-hosted [self-hosted, eshars, {tier}]workload SPwc-secrets (in-spoke data-plane writes to the RV KV)
tier-vms.ymlInfra: 5 - VMsself-hostedworkload SPMatrix 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.ymlInfra: 6 - Environment Workloadself-hostedworkload SPworkload-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 SPWorkload deploy SP
App namegithub-eshars-platformgithub-eshars-deploy
GitHub variablevars.AZURE_PLATFORM_CLIENT_IDvars.AZURE_CLIENT_ID
GitHub Environmentplatformops
RunsInfra 1–3 (cpc, dns-zones, dns-fwd, wn, wc, ops VM)Infra 4–6, runbook deploys, Port actions
Federated credentialsref: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 -reconfigureplan -out=tfplan.bin → prints the plan tail → destroy-guardapply -auto-approve tfplan.bin (the exact planned file — no plan/apply drift window).

  • Destroy-guard: parses tofu show -json and hard-fails if the plan deletes any of ~21 load-bearing azurerm_* 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=true on 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 actionBacking workflowWhat it does
infra_standupinfra-standup.ymlTier stand-up / resume, plan or apply
Restore Databaseport-restore-db.ymlSelf-service BACPAC restore — see Database Restore & Seed (v2 runbooks)
Seed Dev Dataport-seed-dev-data.ymlDev-only synthetic or QA-slice seeding — same page