──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s

### Summary of Changes
  1. Documentation Consolidation:
      • Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
      DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
      SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
      • Updated document links in README.md.
  2. AI Agent Project Map:
      • Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
      schemas, environment variables, and developer workflows.
      • Added PROJECT_MAP.md to .gitignore.
  3. Monitoring UI & Infrastructure Integration:
      • Added monitoring_ui service to docker-compose.yml (port 5175).
      • Configured Gitea workflow docker-build.yml for monitoring_ui builds.
      • Added CORS middleware headers in index.js.
      • Added nginx.conf copy step in Dockerfile.

  ──────
  ### Recommended Commit Message

    docs: consolidate project documentation into monitoring_ui and add agent project map

    - Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
    - Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
    - Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
    - Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
    - Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
    - Configure CORS handling in employee_api for monitoring dashboard access
    ──────
  ### Alternative Short Commit Message

    refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
This commit is contained in:
Sven laptop
2026-09-20 23:33:55 +02:00
parent f935bbeb8c
commit 635a130f0b
195 changed files with 28309 additions and 957 deletions
@@ -0,0 +1,256 @@
#!/usr/bin/env pwsh
# ---------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.md in the project root for license information.
# ---------------------------------------------------------------------------------------------
# Scaffold -> Deploy conformance gate.
# Checks generated IaC against the plan for the semantic defects `az bicep build`
# cannot catch (valid Bicep, wrong values) — the class that causes deploy healing.
#
# Usage: scaffold-conformance.ps1 -SessionPath <.copilot-azure/sessions/{id}> -InfraPath <infra>
# Output: JSON { passed, failures:[{id,detail,file}] } to stdout.
# Exit: 0 = pass, 1 = one or more BLOCK failures.
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$SessionPath,
[string]$InfraPath = 'infra'
)
$failures = New-Object System.Collections.Generic.List[object]
function Add-Fail($id, $detail, $file) {
$failures.Add([ordered]@{ id = $id; detail = $detail; file = $file })
}
function Read-Json($p) {
if (Test-Path $p) { try { return (Get-Content $p -Raw | ConvertFrom-Json) } catch { return $null } }
return $null
}
# --- Load artifacts (a missing input skips only the checks that need it) ---
$plan = Read-Json (Join-Path $SessionPath 'prepare-plan.json')
$mainPath = Join-Path $InfraPath 'main.bicep'
$paramsPath = Join-Path $InfraPath 'main.parameters.json'
$mainBicep = if (Test-Path $mainPath) { Get-Content $mainPath -Raw } else { '' }
$paramsRaw = if (Test-Path $paramsPath) { Get-Content $paramsPath -Raw } else { '' }
$iac = ''
if (Test-Path $InfraPath) {
$iac = (Get-ChildItem $InfraPath -Recurse -Filter *.bicep -ErrorAction SilentlyContinue |
ForEach-Object { Get-Content $_.FullName -Raw }) -join "`n"
}
$svcNames = @()
if ($plan -and $plan.services) { $svcNames = @($plan.services | ForEach-Object { $_.name }) }
function Has-Service($re) { return [bool]($svcNames | Where-Object { $_ -match $re }) }
# 1. TAGS-NO-CAMEL — tag keys must be hyphenated, not Bicep camelCase identifiers.
if ($mainBicep -match 'appOnboard(Skill|SessionId)|createdAt:|deployedBy:') {
Add-Fail 'TAGS-NO-CAMEL' "camelCase tag keys found — use hyphenated ('app-onboard-skill' ...)" 'infra/main.bicep'
}
# 12. DIAG-GATED — we don't scaffold diagnostic-settings; if the generator added one it must be gated behind
# enableDiagnostics (default false) or removed. Unconditional wiring blocks the first deploy (target/workspace not ready).
# Match an actual diagnostic MODULE/RESOURCE declaration, not a prose comment mentioning 'diagnostic'.
if ($mainBicep -match 'module\s+\w*[Dd]iagnostic|Microsoft\.Insights/diagnosticSettings' -and $mainBicep -notmatch 'enableDiagnostics') {
Add-Fail 'DIAG-GATED' 'diagnostic-settings module wired without an enableDiagnostics gate — gate it (module ... = if (enableDiagnostics), default false) or remove it; unconditional wiring blocks the first deploy' 'infra/main.bicep'
}
# 2. NO-PLAINTEXT-SECRET — secrets are @secure() params passed at deploy, never literals.
if ($paramsRaw) {
try {
$pj = $paramsRaw | ConvertFrom-Json
foreach ($k in $pj.parameters.PSObject.Properties.Name) {
if ($k -match '(?i)password|secret|connstring|connection') {
$val = $pj.parameters.$k.value
if ($null -ne $val -and "$val".Trim().Length -gt 0) {
Add-Fail 'NO-PLAINTEXT-SECRET' "parameter '$k' has a literal value — must be @secure(), passed at deploy" 'infra/main.parameters.json'
}
}
}
} catch { }
}
# 2b. NO-BICEP-LITERAL-SECRET — secret values must be @secure() params, never quoted literals in Bicep.
# Property name must end in 'password'/'connectionString' right before ':' (so 'secretName' etc. never match).
if ($iac) {
foreach ($m in [regex]::Matches($iac, "(?im)\b([A-Za-z]*(?:password|connectionstring))\s*:\s*'([^']+)'")) {
Add-Fail 'NO-BICEP-LITERAL-SECRET' "property '$($m.Groups[1].Value)' assigned a literal secret in Bicep — use an @secure() param passed at deploy" 'infra/'
}
if ($iac -match "(?im):\s*'[^']*(?:Password=|AccountKey=|SharedAccessKey=|://[^:@/\s]+:[^:@/\s]+@)[^']*'") {
Add-Fail 'NO-BICEP-LITERAL-SECRET' 'quoted literal contains an embedded credential (Password=/AccountKey=/user:pass@) in Bicep — use an @secure() param' 'infra/'
}
}
# 3. SERVICES-COMPLETE — every planned service maps to a resource type present in the IaC.
$typeMap = [ordered]@{
'container apps environment' = 'Microsoft\.App/managedEnvironments'
'container app' = 'Microsoft\.App/containerApps'
'container registry' = 'Microsoft\.ContainerRegistry/registries'
'mysql' = 'Microsoft\.DBforMySQL/flexibleServers'
'postgres' = 'Microsoft\.DBforPostgreSQL/flexibleServers'
'log analytics' = 'Microsoft\.OperationalInsights/workspaces'
'application insights' = 'Microsoft\.Insights/components'
'static web app' = 'Microsoft\.Web/staticSites'
'app service' = 'Microsoft\.Web/sites'
'functions' = 'Microsoft\.Web/sites'
'sql' = 'Microsoft\.Sql/servers'
'cosmos' = 'Microsoft\.DocumentDB/databaseAccounts'
'redis' = 'Microsoft\.Cache/redis'
'storage' = 'Microsoft\.Storage/storageAccounts'
'service bus' = 'Microsoft\.ServiceBus/namespaces'
'event hub' = 'Microsoft\.EventHub/namespaces'
}
if ($iac) {
foreach ($name in $svcNames) {
$lc = "$name".ToLower(); $expected = $null
foreach ($k in $typeMap.Keys) { if ($lc.Contains($k)) { $expected = $typeMap[$k]; break } }
if ($expected -and ($iac -notmatch $expected)) {
Add-Fail 'SERVICES-COMPLETE' "planned service '$name' has no matching resource ($expected) in IaC" 'infra/'
}
}
}
# --- Managed database checks ---
$hasMysql = Has-Service 'MySQL'
$hasPg = Has-Service 'PostgreSQL|Postgres'
if (($hasMysql -or $hasPg) -and $iac) {
# 4. DB-VERSION-MATCH — each server's version must equal its capabilities-verified plan value.
# Scope the match to the DB resource block so an unrelated 'version:' elsewhere isn't picked up.
$dbTypeRe = @{ 'MySQL' = 'Microsoft\.DBforMySQL/flexibleServers'; 'PostgreSQL' = 'Microsoft\.DBforPostgreSQL/flexibleServers' }
foreach ($svc in ($plan.services | Where-Object { $_.name -match 'MySQL|PostgreSQL' -and $_.version })) {
$kind = if ($svc.name -match 'MySQL') { 'MySQL' } else { 'PostgreSQL' }
$m = [regex]::Match($iac, "$($dbTypeRe[$kind])[\s\S]{0,800}?version:\s*'([^']+)'")
if ($m.Success -and $m.Groups[1].Value -ne $svc.version) {
Add-Fail 'DB-VERSION-MATCH' "$kind IaC version '$($m.Groups[1].Value)' != plan '$($svc.version)'" 'infra/modules'
}
}
# 5. DB-TLS-ON (MySQL) — require_secure_transport must be enforced.
if ($hasMysql -and ($iac -notmatch 'require_secure_transport')) {
Add-Fail 'DB-TLS-ON' 'MySQL module missing require_secure_transport config' 'infra/modules'
}
# 6. DB-NAME-PRESENT — the app's named DB must exist in IaC (from prepare-plan.appDbName).
if ($plan.appDbName -and ($iac -notmatch 'flexibleServers/databases')) {
Add-Fail 'DB-NAME-PRESENT' "app DB '$($plan.appDbName)' declared but no flexibleServers/databases resource" 'infra/modules'
}
# 10. MYSQL-NO-NETWORK-BLOCK (MySQL) — the public-access flow must OMIT the network block.
# An empty delegatedSubnetResourceId/privateDnsZoneResourceId is rejected by ARM (LinkedInvalidPropertyId).
if ($hasMysql -and ($iac -match 'delegatedSubnetResourceId|privateDnsZoneResourceId')) {
Add-Fail 'MYSQL-NO-NETWORK-BLOCK' 'MySQL module includes a network block (delegatedSubnetResourceId) — omit it for public access; an empty value is rejected by ARM' 'infra/modules'
}
# 11. DB-NO-LOCAL-AUTH — data services MUST be Entra / managed-identity only. No admin login/password.
if ($iac -match "administratorLogin\s*:") {
Add-Fail 'DB-NO-LOCAL-AUTH' "administratorLogin/administratorLoginPassword present — databases must be Entra-only (authConfig passwordAuth Disabled + Entra admin). Remove ALL admin login/password params." 'infra/modules'
}
if ($iac -match "passwordAuth\s*:\s*'Enabled'") {
Add-Fail 'DB-NO-LOCAL-AUTH' "PostgreSQL authConfig sets passwordAuth: 'Enabled' — must be 'Disabled' (Entra-only)" 'infra/modules'
}
# 11b. DB-ENTRA-ADMIN — PG/MySQL must set an Entra administrator so migrations run token-based.
if (($hasPg -or $hasMysql) -and ($iac -notmatch 'flexibleServers/administrators')) {
Add-Fail 'DB-ENTRA-ADMIN' 'PostgreSQL/MySQL module missing an administrators (Entra admin) child resource — required for Entra-only auth + token-based migrations' 'infra/modules'
}
# 11c. PG-ENTRA-ONLY — PostgreSQL must explicitly disable password auth.
if ($hasPg -and ($iac -notmatch "passwordAuth\s*:\s*'Disabled'")) {
Add-Fail 'PG-ENTRA-ONLY' "PostgreSQL module missing authConfig { passwordAuth: 'Disabled', activeDirectoryAuth: 'Enabled' }" 'infra/modules'
}
# 11c2. MYSQL-ENTRA-UAMI — MySQL AAD admin requires a user-assigned MI; identityResourceId: null fails to deploy.
if ($hasMysql) {
if ($iac -match 'identityResourceId\s*:\s*null') {
Add-Fail 'MYSQL-ENTRA-UAMI' "MySQL administrators sets identityResourceId: null — will not deploy. Set it to a user-assigned managed identity resourceId and add that UAMI to the server's identity block." 'infra/modules'
} elseif ($iac -notmatch 'identityResourceId') {
Add-Fail 'MYSQL-ENTRA-UAMI' "MySQL administrators missing identityResourceId — MySQL Entra admin REQUIRES a user-assigned managed identity (unlike PostgreSQL)." 'infra/modules'
}
}
}
# --- No shared-key / access-key / local-auth on any data service (managed-identity only) ---
if ($iac) {
# 11d. STORAGE-NO-SHARED-KEY
if (($iac -match 'Microsoft\.Storage/storageAccounts') -and ($iac -notmatch 'allowSharedKeyAccess\s*:\s*false')) {
Add-Fail 'STORAGE-NO-SHARED-KEY' 'Storage account must set allowSharedKeyAccess: false (managed-identity only)' 'infra/modules'
}
# 11e. REDIS-NO-KEYS
if (($iac -match 'Microsoft\.Cache/redis') -and ($iac -notmatch 'disableAccessKeyAuthentication\s*:\s*true')) {
Add-Fail 'REDIS-NO-KEYS' 'Redis must set disableAccessKeyAuthentication: true + accessPolicyAssignments (Entra only)' 'infra/modules'
}
# 11f. COSMOS-NO-LOCAL-AUTH
if (($iac -match 'Microsoft\.DocumentDB/databaseAccounts') -and ($iac -notmatch 'disableLocalAuth\s*:\s*true')) {
Add-Fail 'COSMOS-NO-LOCAL-AUTH' 'Cosmos DB must set disableLocalAuth: true (data-plane RBAC only)' 'infra/modules'
}
# 11g. SBUS-EHUB-NO-LOCAL-AUTH
if (($iac -match 'Microsoft\.ServiceBus/namespaces|Microsoft\.EventHub/namespaces') -and ($iac -notmatch 'disableLocalAuth\s*:\s*true')) {
Add-Fail 'MSG-NO-LOCAL-AUTH' 'Service Bus / Event Hubs namespace must set disableLocalAuth: true (managed-identity only)' 'infra/modules'
}
# 11h. NO-FREE-SKU — free/shared compute tiers are not allowed (managed identity is mandatory).
if ($iac -match "name\s*:\s*'(F1|D1)'") {
Add-Fail 'NO-FREE-SKU' "App Service plan uses a free/shared SKU ('$($Matches[1])') — floor is B1 (managed identity is mandatory; the free-tier MI sidecar OOMs)" 'infra/modules'
}
if (($iac -match 'Microsoft\.Web/staticSites') -and ($iac -match "tier\s*:\s*'Free'")) {
Add-Fail 'NO-FREE-SKU' "Static Web App uses the Free tier — floor is Standard" 'infra/modules'
}
}
# --- Key Vault checks ---
# --- No Key Vault (app-internal secrets live on the compute resource) ---
if (($iac -match 'Microsoft\.KeyVault/vaults') -or ($iac -match '@Microsoft\.KeyVault') -or ($iac -match 'keyVaultUrl')) {
Add-Fail 'NO-KEYVAULT' 'Key Vault detected (Microsoft.KeyVault/vaults, @Microsoft.KeyVault(...), or keyVaultUrl) — no Key Vault is allowed. Store app-internal secrets on the compute resource: @secure() param -> App Service appSettings / Container Apps native secrets[].' 'infra/'
}
# --- Container Apps / ACR checks (deterministic ARM-failure invariants) ---
$hasCA = $iac -match 'Microsoft\.App/containerApps'
$hasCAE = $iac -match 'Microsoft\.App/managedEnvironments'
$hasAcr = $iac -match 'Microsoft\.ContainerRegistry/registries'
if ($iac) {
# 13. CAE-APPLOGS — managedEnvironments log config MUST nest under appLogsConfiguration; a bare
# top-level logAnalyticsConfiguration fails deploy with ManagedEnvironmentInvalidSchema.
if ($hasCAE -and ($iac -match 'logAnalyticsConfiguration') -and ($iac -notmatch 'appLogsConfiguration')) {
Add-Fail 'CAE-APPLOGS' "Container Apps Environment uses a bare logAnalyticsConfiguration — nest it under appLogsConfiguration.destination='log-analytics' (else ManagedEnvironmentInvalidSchema at deploy)" 'infra/modules'
}
# 14. CA-NO-REVISION-SUFFIX — a hardcoded revisionSuffix fails Phase 2 redeploy ('revision already exists').
if ($hasCA -and ($iac -match 'revisionSuffix\s*:')) {
Add-Fail 'CA-NO-REVISION-SUFFIX' 'Container App sets revisionSuffix — omit it (ARM auto-generates); a hardcoded value fails Phase 2 redeploy' 'infra/modules'
}
# 15. CA-IMAGE-PARAM — two-phase deploy needs `param containerImage` in main.bicep, else the Phase 2 `--parameters containerImage=` override is silently ignored and the placeholder image persists.
if ($hasCA -and $hasAcr -and ($mainBicep -notmatch 'param\s+containerImage')) {
Add-Fail 'CA-IMAGE-PARAM' 'main.bicep lacks `param containerImage` — the Phase 2 image override is silently ignored and the placeholder persists (MANIFEST_UNKNOWN)' 'infra/main.bicep'
}
# 16. ACRPULL-GUID — near-miss detector: the fixed AcrPull prefix present without the canonical full GUID = wrong last segment (hallucinated) → RoleDefinitionDoesNotExist. Cannot false-positive.
if (($iac -match '7f951dda-4ed3-4680-a7ca-') -and ($iac -notmatch '7f951dda-4ed3-4680-a7ca-43fe172d538d')) {
Add-Fail 'ACRPULL-GUID' 'AcrPull role GUID is wrong — canonical value is 7f951dda-4ed3-4680-a7ca-43fe172d538d (RoleDefinitionDoesNotExist otherwise)' 'infra/modules/role-assignments.bicep'
}
# 17. ACR-NO-PREMIUM-POLICY — retention/trust/quarantine are Premium-only; on Basic/Standard ACR they fail SkuNotSupported. FP-safe: skipped if any 'Premium' SKU appears in the IaC.
if ($hasAcr -and ($iac -match '(retentionPolicy|trustPolicy|quarantinePolicy)\s*:') -and ($iac -notmatch "'Premium'")) {
Add-Fail 'ACR-NO-PREMIUM-POLICY' 'ACR has a Premium-only policy (retention/trust/quarantine) but is not Premium — omit these for Basic/Standard (SkuNotSupported at deploy)' 'infra/modules'
}
}
# 9. WARN-FIXED — prereq warnings flagged fixPhase:"scaffold" must land in the generated IaC.
# Only warnings with a provable signal are enforced; unverifiable ones are skipped (no false BLOCK).
# Extend $warnSignal as new provable scaffold-phase warning IDs are added.
$prereq = Read-Json (Join-Path $SessionPath 'prereq-output.json')
if ($prereq -and $prereq.warnings) {
$warnSignal = @{
'W-PG-SSL' = @{ kind = 'iac'; pattern = 'PGSSLMODE|sslmode' } # SSL-mode env var emitted in IaC
'W-BUILDKIT' = @{ kind = 'file'; pattern = 'Dockerfile.azure' } # ACR-compatible Dockerfile emitted
}
foreach ($w in $prereq.warnings) {
if ($w.fixPhase -ne 'scaffold') { continue }
$sig = $warnSignal[$w.id]
if (-not $sig) { continue } # no deterministic signal — cannot verify, do not block
$present = if ($sig.kind -eq 'file') {
[bool](Get-ChildItem -Recurse -Filter $sig.pattern -ErrorAction SilentlyContinue | Select-Object -First 1)
} else {
[bool]($iac -match $sig.pattern)
}
if (-not $present) {
Add-Fail 'WARN-FIXED' "prereq warning '$($w.id)' (fixPhase:scaffold) fix not found in IaC — $($w.fix)" 'infra/'
}
}
}
$result = [ordered]@{ passed = ($failures.Count -eq 0); failures = $failures }
$result | ConvertTo-Json -Depth 5 -Compress
if ($failures.Count -gt 0) { exit 1 } else { exit 0 }
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.md in the project root for license information.
# ---------------------------------------------------------------------------------------------
# Scaffold -> Deploy conformance gate (bash twin of scaffold-conformance.ps1).
# Checks generated IaC against the plan for semantic defects `az bicep build` cannot catch.
#
# Usage: scaffold-conformance.sh <sessionPath> [infraPath]
# Output: JSON { passed, failures:[{id,detail,file}] } to stdout.
# Exit: 0 = pass, 1 = one or more BLOCK failures.
#
# Pure-text checks (tags, TLS, purge, deployer role, plaintext secret) always run.
# Plan-dependent checks (version, services-complete, db-name, warn-fixed) run only when `jq` is present.
set -u
SESSION_PATH="${1:?usage: scaffold-conformance.sh <sessionPath> [infraPath]}"
INFRA_PATH="${2:-infra}"
PLAN="$SESSION_PATH/prepare-plan.json"
MAIN="$INFRA_PATH/main.bicep"
PARAMS="$INFRA_PATH/main.parameters.json"
main_bicep="$( [ -f "$MAIN" ] && cat "$MAIN" || true )"
params_raw="$( [ -f "$PARAMS" ] && cat "$PARAMS" || true )"
iac="$( find "$INFRA_PATH" -name '*.bicep' -type f 2>/dev/null -exec cat {} + || true )"
HAVE_JQ=0; command -v jq >/dev/null 2>&1 && HAVE_JQ=1
# failures accumulator (JSON objects, newline-separated)
fails=""
add_fail() { # id detail file
local obj; obj="$(printf '{"id":"%s","detail":"%s","file":"%s"}' "$1" "$2" "$3")"
fails="${fails:+$fails,}$obj"
}
iac_has() { printf '%s' "$iac" | grep -qE "$1"; }
# 1. TAGS-NO-CAMEL
if printf '%s' "$main_bicep" | grep -qE 'appOnboard(Skill|SessionId)|createdAt:|deployedBy:'; then
add_fail "TAGS-NO-CAMEL" "camelCase tag keys found — use hyphenated ('app-onboard-skill' ...)" "infra/main.bicep"
fi
# 12. DIAG-GATED — we don't scaffold diagnostic-settings; if present it must be gated behind enableDiagnostics (default false) or removed. Unconditional wiring blocks the first deploy.
# Match an actual diagnostic module/resource declaration, not a prose comment mentioning 'diagnostic'.
if printf '%s' "$main_bicep" | grep -qE 'module[[:space:]]+[A-Za-z]*[Dd]iagnostic|Microsoft\.Insights/diagnosticSettings' && ! printf '%s' "$main_bicep" | grep -q 'enableDiagnostics'; then
add_fail "DIAG-GATED" "diagnostic-settings module wired without an enableDiagnostics gate — gate it (module ... = if (enableDiagnostics), default false) or remove it; unconditional wiring blocks the first deploy" "infra/main.bicep"
fi
# 2. NO-PLAINTEXT-SECRET (jq-free: awk over the params file so it runs on hosts without jq)
if [ -n "$params_raw" ]; then
bad_keys="$(printf '%s' "$params_raw" | awk '
{ line=tolower($0) }
line ~ /"[a-z0-9_]*(password|secret|connstring|connection)[a-z0-9_]*"[ \t]*:/ {
n=split($0, parts, "\"")
for (i=1;i<=n;i++) { if (tolower(parts[i]) ~ /(password|secret|connstring|connection)/) { pend=parts[i]; break } }
}
pend != "" && line ~ /"value"[ \t]*:[ \t]*"[^"]+"/ { print pend; pend="" }
/}/ { pend="" }
')"
if [ -n "$bad_keys" ]; then
while IFS= read -r k; do
[ -z "$k" ] && continue
add_fail "NO-PLAINTEXT-SECRET" "parameter '$k' has a literal value — must be @secure(), passed at deploy" "infra/main.parameters.json"
done <<< "$bad_keys"
fi
fi
# 2b. NO-BICEP-LITERAL-SECRET (pure-text) — secret values must be @secure() params, never quoted literals.
# Property name must end in 'password'/'connectionString' right before ':' (so 'secretName' etc. never match).
if [ -n "$iac" ]; then
if printf '%s' "$iac" | grep -qiE "[A-Za-z]*(password|connectionstring)[[:space:]]*:[[:space:]]*'[^']+'"; then
k="$(printf '%s' "$iac" | grep -oiE "[A-Za-z]*(password|connectionstring)[[:space:]]*:[[:space:]]*'[^']+'" | head -n1 | sed -E "s/[[:space:]]*:.*//")"
add_fail "NO-BICEP-LITERAL-SECRET" "property '$k' assigned a literal secret in Bicep — use an @secure() param passed at deploy" "infra/"
fi
if printf '%s' "$iac" | grep -qE ":[[:space:]]*'[^']*(Password=|AccountKey=|SharedAccessKey=|://[^:@/ ]+:[^:@/ ]+@)[^']*'"; then
add_fail "NO-BICEP-LITERAL-SECRET" "quoted literal contains an embedded credential (Password=/AccountKey=/user:pass@) in Bicep — use an @secure() param" "infra/"
fi
fi
# service-name -> resource-type map (longest keys first so 'container apps environment' wins)
declare -a MAP_KEYS=( "container apps environment" "container app" "container registry" "mysql" "postgres" \
"log analytics" "application insights" "static web app" "app service" "functions" \
"sql" "cosmos" "redis" "storage" "service bus" "event hub" )
map_type() {
case "$1" in
*"container apps environment"*) echo 'Microsoft\.App/managedEnvironments';;
*"container app"*) echo 'Microsoft\.App/containerApps';;
*"container registry"*) echo 'Microsoft\.ContainerRegistry/registries';;
*mysql*) echo 'Microsoft\.DBforMySQL/flexibleServers';;
*postgres*) echo 'Microsoft\.DBforPostgreSQL/flexibleServers';;
*"log analytics"*) echo 'Microsoft\.OperationalInsights/workspaces';;
*"application insights"*) echo 'Microsoft\.Insights/components';;
*"static web app"*) echo 'Microsoft\.Web/staticSites';;
*"app service"*|*functions*) echo 'Microsoft\.Web/sites';;
*sql*) echo 'Microsoft\.Sql/servers';;
*cosmos*) echo 'Microsoft\.DocumentDB/databaseAccounts';;
*redis*) echo 'Microsoft\.Cache/redis';;
*storage*) echo 'Microsoft\.Storage/storageAccounts';;
*"service bus"*) echo 'Microsoft\.ServiceBus/namespaces';;
*"event hub"*) echo 'Microsoft\.EventHub/namespaces';;
*) echo '';;
esac
}
svc_names=""; db_version=""; app_db_name=""; has_mysql=0; has_pg=0
if [ "$HAVE_JQ" = 1 ] && [ -f "$PLAN" ]; then
svc_names="$(jq -r '.services[].name' "$PLAN" 2>/dev/null)"
app_db_name="$(jq -r '.appDbName // empty' "$PLAN" 2>/dev/null)"
printf '%s' "$svc_names" | grep -qiE 'mysql' && has_mysql=1
printf '%s' "$svc_names" | grep -qiE 'postgres' && has_pg=1
# 3. SERVICES-COMPLETE
while IFS= read -r name; do
[ -z "$name" ] && continue
lc="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"
expected="$(map_type "$lc")"
if [ -n "$expected" ] && ! iac_has "$expected"; then
add_fail "SERVICES-COMPLETE" "planned service '$name' has no matching resource ($expected) in IaC" "infra/"
fi
done < <(printf '%s\n' "$svc_names")
else
# jq absent: fall back to grep for the DB-type presence (drives TLS/name checks below)
printf '%s' "$iac" | grep -qE 'Microsoft\.DBforMySQL/flexibleServers' && has_mysql=1
printf '%s' "$iac" | grep -qE 'Microsoft\.DBforPostgreSQL/flexibleServers' && has_pg=1
fi
# --- Managed database checks ---
if [ "$has_mysql" = 1 ] || [ "$has_pg" = 1 ]; then
# 4. DB-VERSION-MATCH — each server's version must equal its capabilities-verified plan value.
# Scope the match to the DB resource block so an unrelated 'version:' elsewhere isn't picked up.
if [ "$HAVE_JQ" = 1 ] && [ -f "$PLAN" ]; then
while IFS=$'\t' read -r dbkind dbver; do
[ -z "$dbver" ] && continue
case "$dbkind" in
*MySQL*) marker='Microsoft[.]DBforMySQL/flexibleServers';;
*) marker='Microsoft[.]DBforPostgreSQL/flexibleServers';;
esac
iac_ver="$(printf '%s\n' "$iac" | awk -v marker="$marker" '
$0 ~ marker { armed=1 }
armed==1 && match($0, /version:[ \t]*'"'"'[^'"'"']+'"'"'/) {
v=substr($0,RSTART,RLENGTH); sub(/version:[ \t]*'"'"'/,"",v); sub(/'"'"'.*/,"",v); print v; exit
}')"
if [ -n "$iac_ver" ] && [ "$iac_ver" != "$dbver" ]; then
add_fail "DB-VERSION-MATCH" "$dbkind IaC version '$iac_ver' != plan '$dbver'" "infra/modules"
fi
done < <(jq -r '.services[]? | select(.name|test("MySQL|PostgreSQL")) | [.name, (.version // "")] | @tsv' "$PLAN" 2>/dev/null)
fi
# 5. DB-TLS-ON (MySQL, pure-text)
if [ "$has_mysql" = 1 ] && ! iac_has 'require_secure_transport'; then
add_fail "DB-TLS-ON" "MySQL module missing require_secure_transport config" "infra/modules"
fi
# 6. DB-NAME-PRESENT (needs plan.appDbName from jq)
if [ -n "$app_db_name" ] && ! iac_has 'flexibleServers/databases'; then
add_fail "DB-NAME-PRESENT" "app DB '$app_db_name' declared but no flexibleServers/databases resource" "infra/modules"
fi
# 10. MYSQL-NO-NETWORK-BLOCK (MySQL, pure-text) — public flow must omit the network block;
# an empty delegatedSubnetResourceId is rejected by ARM (LinkedInvalidPropertyId).
if [ "$has_mysql" = 1 ] && iac_has 'delegatedSubnetResourceId|privateDnsZoneResourceId'; then
add_fail "MYSQL-NO-NETWORK-BLOCK" "MySQL module includes a network block (delegatedSubnetResourceId) — omit it for public access; an empty value is rejected by ARM" "infra/modules"
fi
# 11. DB-NO-LOCAL-AUTH (pure-text) — databases MUST be Entra / managed-identity only.
if iac_has "administratorLogin[[:space:]]*:"; then
add_fail "DB-NO-LOCAL-AUTH" "administratorLogin/administratorLoginPassword present — databases must be Entra-only (authConfig passwordAuth Disabled + Entra admin). Remove ALL admin login/password params." "infra/modules"
fi
if iac_has "passwordAuth[[:space:]]*:[[:space:]]*'Enabled'"; then
add_fail "DB-NO-LOCAL-AUTH" "PostgreSQL authConfig sets passwordAuth: 'Enabled' — must be 'Disabled' (Entra-only)" "infra/modules"
fi
# 11b. DB-ENTRA-ADMIN — PG/MySQL must set an Entra administrator for token-based migrations.
if ! iac_has 'flexibleServers/administrators'; then
add_fail "DB-ENTRA-ADMIN" "PostgreSQL/MySQL module missing an administrators (Entra admin) child resource — required for Entra-only auth + token-based migrations" "infra/modules"
fi
# 11c. PG-ENTRA-ONLY — PostgreSQL must explicitly disable password auth.
if [ "$has_pg" = 1 ] && ! iac_has "passwordAuth[[:space:]]*:[[:space:]]*'Disabled'"; then
add_fail "PG-ENTRA-ONLY" "PostgreSQL module missing authConfig { passwordAuth: 'Disabled', activeDirectoryAuth: 'Enabled' }" "infra/modules"
fi
# 11c2. MYSQL-ENTRA-UAMI — MySQL AAD admin requires a user-assigned MI; identityResourceId: null fails to deploy.
if [ "$has_mysql" = 1 ]; then
if iac_has "identityResourceId[[:space:]]*:[[:space:]]*null"; then
add_fail "MYSQL-ENTRA-UAMI" "MySQL administrators sets identityResourceId: null — will not deploy. Set it to a user-assigned managed identity resourceId and add that UAMI to the server's identity block." "infra/modules"
elif ! iac_has 'identityResourceId'; then
add_fail "MYSQL-ENTRA-UAMI" "MySQL administrators missing identityResourceId — MySQL Entra admin REQUIRES a user-assigned managed identity (unlike PostgreSQL)." "infra/modules"
fi
fi
fi
# --- No shared-key / access-key / local-auth on any data service (managed-identity only) ---
# 11d. STORAGE-NO-SHARED-KEY
if iac_has 'Microsoft\.Storage/storageAccounts' && ! iac_has "allowSharedKeyAccess[[:space:]]*:[[:space:]]*false"; then
add_fail "STORAGE-NO-SHARED-KEY" "Storage account must set allowSharedKeyAccess: false (managed-identity only)" "infra/modules"
fi
# 11e. REDIS-NO-KEYS
if iac_has 'Microsoft\.Cache/redis' && ! iac_has "disableAccessKeyAuthentication[[:space:]]*:[[:space:]]*true"; then
add_fail "REDIS-NO-KEYS" "Redis must set disableAccessKeyAuthentication: true + accessPolicyAssignments (Entra only)" "infra/modules"
fi
# 11f. COSMOS-NO-LOCAL-AUTH
if iac_has 'Microsoft\.DocumentDB/databaseAccounts' && ! iac_has "disableLocalAuth[[:space:]]*:[[:space:]]*true"; then
add_fail "COSMOS-NO-LOCAL-AUTH" "Cosmos DB must set disableLocalAuth: true (data-plane RBAC only)" "infra/modules"
fi
# 11g. MSG-NO-LOCAL-AUTH
if iac_has 'Microsoft\.ServiceBus/namespaces|Microsoft\.EventHub/namespaces' && ! iac_has "disableLocalAuth[[:space:]]*:[[:space:]]*true"; then
add_fail "MSG-NO-LOCAL-AUTH" "Service Bus / Event Hubs namespace must set disableLocalAuth: true (managed-identity only)" "infra/modules"
fi
# 11h. NO-FREE-SKU — free/shared compute tiers are not allowed (managed identity is mandatory).
if iac_has "name[[:space:]]*:[[:space:]]*'(F1|D1)'"; then
sku="$(printf '%s' "$iac" | grep -oE "name[[:space:]]*:[[:space:]]*'(F1|D1)'" | grep -oE '(F1|D1)' | head -n1)"
add_fail "NO-FREE-SKU" "App Service plan uses a free/shared SKU ('${sku}') — floor is B1 (managed identity is mandatory; the free-tier MI sidecar OOMs)" "infra/modules"
fi
if iac_has 'Microsoft\.Web/staticSites' && iac_has "tier[[:space:]]*:[[:space:]]*'Free'"; then
add_fail "NO-FREE-SKU" "Static Web App uses the Free tier — floor is Standard" "infra/modules"
fi
# --- No Key Vault (app-internal secrets live on the compute resource) ---
if iac_has 'Microsoft\.KeyVault/vaults' || iac_has '@Microsoft\.KeyVault' || iac_has 'keyVaultUrl'; then
add_fail "NO-KEYVAULT" "Key Vault detected (Microsoft.KeyVault/vaults, @Microsoft.KeyVault(...), or keyVaultUrl) — no Key Vault is allowed. Store app-internal secrets on the compute resource: @secure() param -> App Service appSettings / Container Apps native secrets[]." "infra/"
fi
# --- Container Apps / ACR checks (deterministic ARM-failure invariants) ---
if [ -n "$iac" ]; then
has_ca=0; iac_has 'Microsoft\.App/containerApps' && has_ca=1
has_cae=0; iac_has 'Microsoft\.App/managedEnvironments' && has_cae=1
has_acr=0; iac_has 'Microsoft\.ContainerRegistry/registries' && has_acr=1
# 13. CAE-APPLOGS — managedEnvironments log config MUST nest under appLogsConfiguration; a bare
# top-level logAnalyticsConfiguration fails deploy with ManagedEnvironmentInvalidSchema.
if [ "$has_cae" = 1 ] && iac_has 'logAnalyticsConfiguration' && ! iac_has 'appLogsConfiguration'; then
add_fail "CAE-APPLOGS" "Container Apps Environment uses a bare logAnalyticsConfiguration — nest it under appLogsConfiguration.destination='log-analytics' (else ManagedEnvironmentInvalidSchema at deploy)" "infra/modules"
fi
# 14. CA-NO-REVISION-SUFFIX — a hardcoded revisionSuffix fails Phase 2 redeploy ('revision already exists').
if [ "$has_ca" = 1 ] && iac_has 'revisionSuffix[[:space:]]*:'; then
add_fail "CA-NO-REVISION-SUFFIX" "Container App sets revisionSuffix — omit it (ARM auto-generates); a hardcoded value fails Phase 2 redeploy" "infra/modules"
fi
# 15. CA-IMAGE-PARAM — two-phase deploy needs 'param containerImage' in main.bicep, else the Phase 2 override is silently ignored and the placeholder image persists.
if [ "$has_ca" = 1 ] && [ "$has_acr" = 1 ] && ! printf '%s' "$main_bicep" | grep -qE 'param[[:space:]]+containerImage'; then
add_fail "CA-IMAGE-PARAM" "main.bicep lacks 'param containerImage' — the Phase 2 image override is silently ignored and the placeholder persists (MANIFEST_UNKNOWN)" "infra/main.bicep"
fi
# 16. ACRPULL-GUID — near-miss detector: fixed AcrPull prefix present without the canonical full GUID = hallucinated last segment → RoleDefinitionDoesNotExist. Cannot false-positive.
if iac_has '7f951dda-4ed3-4680-a7ca-' && ! iac_has '7f951dda-4ed3-4680-a7ca-43fe172d538d'; then
add_fail "ACRPULL-GUID" "AcrPull role GUID is wrong — canonical value is 7f951dda-4ed3-4680-a7ca-43fe172d538d (RoleDefinitionDoesNotExist otherwise)" "infra/modules/role-assignments.bicep"
fi
# 17. ACR-NO-PREMIUM-POLICY — retention/trust/quarantine are Premium-only; on Basic/Standard ACR they fail SkuNotSupported. FP-safe: skipped if any 'Premium' SKU appears.
if [ "$has_acr" = 1 ] && iac_has '(retentionPolicy|trustPolicy|quarantinePolicy)[[:space:]]*:' && ! iac_has "'Premium'"; then
add_fail "ACR-NO-PREMIUM-POLICY" "ACR has a Premium-only policy (retention/trust/quarantine) but is not Premium — omit these for Basic/Standard (SkuNotSupported at deploy)" "infra/modules"
fi
fi
# 9. WARN-FIXED (needs jq to read prereq-output.json warnings[]) — prereq warnings flagged
# fixPhase:"scaffold" must land in the IaC. Only warnings with a provable signal are enforced;
# unverifiable ones are skipped (no false BLOCK). Extend the case map for new provable warning IDs.
PREREQ="$SESSION_PATH/prereq-output.json"
if [ "$HAVE_JQ" = 1 ] && [ -f "$PREREQ" ]; then
while IFS=$'\t' read -r wid wfix; do
[ -z "$wid" ] && continue
present=0
case "$wid" in
W-PG-SSL) printf '%s' "$iac" | grep -qiE 'PGSSLMODE|sslmode' && present=1 ;;
W-BUILDKIT) [ -n "$(find . -name 'Dockerfile.azure' -type f 2>/dev/null | head -n1)" ] && present=1 ;;
*) continue ;; # no deterministic signal — cannot verify, do not block
esac
if [ "$present" != 1 ]; then
add_fail "WARN-FIXED" "prereq warning '$wid' (fixPhase:scaffold) fix not found in IaC — $wfix" "infra/"
fi
done < <(jq -r '.warnings[]? | select(.fixPhase=="scaffold") | [.id, .fix] | @tsv' "$PREREQ" 2>/dev/null)
fi
if [ -z "$fails" ]; then
printf '{"passed":true,"failures":[]}\n'
exit 0
else
printf '{"passed":false,"failures":[%s]}\n' "$fails"
exit 1
fi