|
10 | 10 | # - copyrit-gui-common (azureServiceConnection, acrName, acrLoginServer, imageName) |
11 | 11 | # - copyrit-gui-test (resourceGroup, appName, entraTenantId, entraClientId, |
12 | 12 | # allowedGroupObjectIds, sqlServerFqdn, sqlDatabaseName, |
13 | | -# keyVaultResourceId, acrResourceId, enablePrivateEndpoint, enableOtel) |
| 13 | +# keyVaultResourceId, acrResourceId, enablePrivateEndpoint, enableOtel, |
| 14 | +# envFileContents [SECURE]) |
14 | 15 | # - copyrit-gui-prod (same keys as test, with production values) |
| 16 | +# |
| 17 | +# The `envFileContents` variable must be marked SECURE in the ADO library. |
| 18 | +# It contains the entire .env file (endpoints, keys, etc.) and is passed to |
| 19 | +# the Bicep template as the @secure() `envFileContents` parameter via a |
| 20 | +# parameters JSON file written to disk in the runner. Writing to a file |
| 21 | +# (instead of `--parameters envFileContents=$(envFileContents)`) avoids |
| 22 | +# exposing the value in the runner's process list / `ps` output. |
15 | 23 |
|
16 | 24 | trigger: |
17 | 25 | branches: |
@@ -118,21 +126,97 @@ stages: |
118 | 126 | inlineScript: | |
119 | 127 | set -euo pipefail |
120 | 128 |
|
| 129 | + # Write deployment parameters to a temp file so the |
| 130 | + # @secure() envFileContents value never appears on the |
| 131 | + # az CLI command line (visible in process list). |
| 132 | + PARAMS_FILE="$(mktemp --suffix=.parameters.json)" |
| 133 | + trap 'rm -f "$PARAMS_FILE"' EXIT |
| 134 | +
|
| 135 | + python3 - <<'PY' > "$PARAMS_FILE" |
| 136 | + import json, os, sys |
| 137 | +
|
| 138 | +
|
| 139 | + def parse_bool(name: str) -> bool: |
| 140 | + """Strict bool parse: fail loudly if the ADO variable was unresolved. |
| 141 | +
|
| 142 | + ADO leaves unresolved macro variables as the literal string |
| 143 | + '$(varname)'. Without this guard, that string would silently |
| 144 | + coerce to False (since 'lower() == "true"' is False), which can |
| 145 | + deploy with the wrong network posture (e.g., public access on |
| 146 | + when private endpoint was intended). Fail loudly instead. |
| 147 | + """ |
| 148 | + val = os.environ.get(name, "") |
| 149 | + if val.lower() in ("true", "false"): |
| 150 | + return val.lower() == "true" |
| 151 | + sys.exit( |
| 152 | + f"ERROR: Pipeline variable {name} must be 'true' or 'false', " |
| 153 | + f"got: {val!r}. Check the ADO variable group (likely the " |
| 154 | + f"variable is missing or misspelled — ADO leaves unresolved " |
| 155 | + f"macros as the literal string '$(name)')." |
| 156 | + ) |
| 157 | +
|
| 158 | +
|
| 159 | + def parse_secret_str(name: str) -> str: |
| 160 | + """Fail loudly if a SECURE pipeline variable was unresolved. |
| 161 | +
|
| 162 | + Same root cause as parse_bool: ADO leaves unresolved macros as |
| 163 | + '$(varname)'. For securestring parameters (envFileContents), Azure |
| 164 | + accepts ANY string, so an unresolved macro silently deploys garbage |
| 165 | + that the container can't parse. Detect the pattern and abort. |
| 166 | + """ |
| 167 | + val = os.environ.get(name, "") |
| 168 | + # Match exactly '$(identifier)' — ADO macro syntax for unresolved variables. |
| 169 | + if val.startswith("$(") and val.endswith(")") and "(" not in val[2:-1]: |
| 170 | + sys.exit( |
| 171 | + f"ERROR: Pipeline variable {name} appears unresolved (got " |
| 172 | + f"'{val}'). The ADO library variable is likely missing or " |
| 173 | + f"misspelled (ADO leaves unresolved macros as the literal " |
| 174 | + f"string '$(name)'). For envFileContents, also confirm the " |
| 175 | + f"variable is marked SECURE in the ADO library." |
| 176 | + ) |
| 177 | + if not val: |
| 178 | + sys.exit(f"ERROR: Pipeline variable {name} is empty.") |
| 179 | + return val |
| 180 | +
|
| 181 | +
|
| 182 | + doc = { |
| 183 | + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", |
| 184 | + "contentVersion": "1.0.0.0", |
| 185 | + "parameters": { |
| 186 | + "appName": {"value": os.environ["APP_NAME"]}, |
| 187 | + "containerImage": {"value": os.environ["CONTAINER_IMAGE"]}, |
| 188 | + "entraTenantId": {"value": os.environ["ENTRA_TENANT_ID"]}, |
| 189 | + "entraClientId": {"value": os.environ["ENTRA_CLIENT_ID"]}, |
| 190 | + "allowedGroupObjectIds": {"value": os.environ["ALLOWED_GROUP_OBJECT_IDS"]}, |
| 191 | + "sqlServerFqdn": {"value": os.environ["SQL_SERVER_FQDN"]}, |
| 192 | + "sqlDatabaseName": {"value": os.environ["SQL_DATABASE_NAME"]}, |
| 193 | + "keyVaultResourceId": {"value": os.environ["KEY_VAULT_RESOURCE_ID"]}, |
| 194 | + "acrResourceId": {"value": os.environ["ACR_RESOURCE_ID"]}, |
| 195 | + "enablePrivateEndpoint": {"value": parse_bool("ENABLE_PRIVATE_ENDPOINT")}, |
| 196 | + "enableOtel": {"value": parse_bool("ENABLE_OTEL")}, |
| 197 | + "envFileContents": {"value": parse_secret_str("ENV_FILE_CONTENTS")}, |
| 198 | + }, |
| 199 | + } |
| 200 | + print(json.dumps(doc)) |
| 201 | + PY |
| 202 | +
|
121 | 203 | az deployment group create \ |
122 | 204 | --resource-group $(resourceGroup) \ |
123 | 205 | --template-file $(Build.SourcesDirectory)/infra/main.bicep \ |
124 | | - --parameters appName=$(appName) \ |
125 | | - --parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \ |
126 | | - --parameters entraTenantId=$(entraTenantId) \ |
127 | | - --parameters entraClientId=$(entraClientId) \ |
128 | | - --parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \ |
129 | | - --parameters sqlServerFqdn=$(sqlServerFqdn) \ |
130 | | - --parameters sqlDatabaseName=$(sqlDatabaseName) \ |
131 | | - --parameters keyVaultResourceId=$(keyVaultResourceId) \ |
132 | | - --parameters acrResourceId=$(acrResourceId) \ |
133 | | - --parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \ |
134 | | - --parameters enableOtel=$(enableOtel) \ |
135 | | - --parameters envSecretName=$(envSecretName) |
| 206 | + --parameters @"$PARAMS_FILE" |
| 207 | + env: |
| 208 | + APP_NAME: $(appName) |
| 209 | + CONTAINER_IMAGE: $(acrLoginServer)/$(imageName):$(Build.SourceVersion) |
| 210 | + ENTRA_TENANT_ID: $(entraTenantId) |
| 211 | + ENTRA_CLIENT_ID: $(entraClientId) |
| 212 | + ALLOWED_GROUP_OBJECT_IDS: $(allowedGroupObjectIds) |
| 213 | + SQL_SERVER_FQDN: $(sqlServerFqdn) |
| 214 | + SQL_DATABASE_NAME: $(sqlDatabaseName) |
| 215 | + KEY_VAULT_RESOURCE_ID: $(keyVaultResourceId) |
| 216 | + ACR_RESOURCE_ID: $(acrResourceId) |
| 217 | + ENABLE_PRIVATE_ENDPOINT: $(enablePrivateEndpoint) |
| 218 | + ENABLE_OTEL: $(enableOtel) |
| 219 | + ENV_FILE_CONTENTS: $(envFileContents) |
136 | 220 |
|
137 | 221 | - task: AzureCLI@2 |
138 | 222 | displayName: 'Health check' |
@@ -189,21 +273,97 @@ stages: |
189 | 273 | inlineScript: | |
190 | 274 | set -euo pipefail |
191 | 275 |
|
| 276 | + # Write deployment parameters to a temp file so the |
| 277 | + # @secure() envFileContents value never appears on the |
| 278 | + # az CLI command line (visible in process list). |
| 279 | + PARAMS_FILE="$(mktemp --suffix=.parameters.json)" |
| 280 | + trap 'rm -f "$PARAMS_FILE"' EXIT |
| 281 | +
|
| 282 | + python3 - <<'PY' > "$PARAMS_FILE" |
| 283 | + import json, os, sys |
| 284 | +
|
| 285 | +
|
| 286 | + def parse_bool(name: str) -> bool: |
| 287 | + """Strict bool parse: fail loudly if the ADO variable was unresolved. |
| 288 | +
|
| 289 | + ADO leaves unresolved macro variables as the literal string |
| 290 | + '$(varname)'. Without this guard, that string would silently |
| 291 | + coerce to False (since 'lower() == "true"' is False), which can |
| 292 | + deploy with the wrong network posture (e.g., public access on |
| 293 | + when private endpoint was intended). Fail loudly instead. |
| 294 | + """ |
| 295 | + val = os.environ.get(name, "") |
| 296 | + if val.lower() in ("true", "false"): |
| 297 | + return val.lower() == "true" |
| 298 | + sys.exit( |
| 299 | + f"ERROR: Pipeline variable {name} must be 'true' or 'false', " |
| 300 | + f"got: {val!r}. Check the ADO variable group (likely the " |
| 301 | + f"variable is missing or misspelled — ADO leaves unresolved " |
| 302 | + f"macros as the literal string '$(name)')." |
| 303 | + ) |
| 304 | +
|
| 305 | +
|
| 306 | + def parse_secret_str(name: str) -> str: |
| 307 | + """Fail loudly if a SECURE pipeline variable was unresolved. |
| 308 | +
|
| 309 | + Same root cause as parse_bool: ADO leaves unresolved macros as |
| 310 | + '$(varname)'. For securestring parameters (envFileContents), Azure |
| 311 | + accepts ANY string, so an unresolved macro silently deploys garbage |
| 312 | + that the container can't parse. Detect the pattern and abort. |
| 313 | + """ |
| 314 | + val = os.environ.get(name, "") |
| 315 | + # Match exactly '$(identifier)' — ADO macro syntax for unresolved variables. |
| 316 | + if val.startswith("$(") and val.endswith(")") and "(" not in val[2:-1]: |
| 317 | + sys.exit( |
| 318 | + f"ERROR: Pipeline variable {name} appears unresolved (got " |
| 319 | + f"'{val}'). The ADO library variable is likely missing or " |
| 320 | + f"misspelled (ADO leaves unresolved macros as the literal " |
| 321 | + f"string '$(name)'). For envFileContents, also confirm the " |
| 322 | + f"variable is marked SECURE in the ADO library." |
| 323 | + ) |
| 324 | + if not val: |
| 325 | + sys.exit(f"ERROR: Pipeline variable {name} is empty.") |
| 326 | + return val |
| 327 | +
|
| 328 | +
|
| 329 | + doc = { |
| 330 | + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", |
| 331 | + "contentVersion": "1.0.0.0", |
| 332 | + "parameters": { |
| 333 | + "appName": {"value": os.environ["APP_NAME"]}, |
| 334 | + "containerImage": {"value": os.environ["CONTAINER_IMAGE"]}, |
| 335 | + "entraTenantId": {"value": os.environ["ENTRA_TENANT_ID"]}, |
| 336 | + "entraClientId": {"value": os.environ["ENTRA_CLIENT_ID"]}, |
| 337 | + "allowedGroupObjectIds": {"value": os.environ["ALLOWED_GROUP_OBJECT_IDS"]}, |
| 338 | + "sqlServerFqdn": {"value": os.environ["SQL_SERVER_FQDN"]}, |
| 339 | + "sqlDatabaseName": {"value": os.environ["SQL_DATABASE_NAME"]}, |
| 340 | + "keyVaultResourceId": {"value": os.environ["KEY_VAULT_RESOURCE_ID"]}, |
| 341 | + "acrResourceId": {"value": os.environ["ACR_RESOURCE_ID"]}, |
| 342 | + "enablePrivateEndpoint": {"value": parse_bool("ENABLE_PRIVATE_ENDPOINT")}, |
| 343 | + "enableOtel": {"value": parse_bool("ENABLE_OTEL")}, |
| 344 | + "envFileContents": {"value": parse_secret_str("ENV_FILE_CONTENTS")}, |
| 345 | + }, |
| 346 | + } |
| 347 | + print(json.dumps(doc)) |
| 348 | + PY |
| 349 | +
|
192 | 350 | az deployment group create \ |
193 | 351 | --resource-group $(resourceGroup) \ |
194 | 352 | --template-file $(Build.SourcesDirectory)/infra/main.bicep \ |
195 | | - --parameters appName=$(appName) \ |
196 | | - --parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \ |
197 | | - --parameters entraTenantId=$(entraTenantId) \ |
198 | | - --parameters entraClientId=$(entraClientId) \ |
199 | | - --parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \ |
200 | | - --parameters sqlServerFqdn=$(sqlServerFqdn) \ |
201 | | - --parameters sqlDatabaseName=$(sqlDatabaseName) \ |
202 | | - --parameters keyVaultResourceId=$(keyVaultResourceId) \ |
203 | | - --parameters acrResourceId=$(acrResourceId) \ |
204 | | - --parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \ |
205 | | - --parameters enableOtel=$(enableOtel) \ |
206 | | - --parameters envSecretName=$(envSecretName) |
| 353 | + --parameters @"$PARAMS_FILE" |
| 354 | + env: |
| 355 | + APP_NAME: $(appName) |
| 356 | + CONTAINER_IMAGE: $(acrLoginServer)/$(imageName):$(Build.SourceVersion) |
| 357 | + ENTRA_TENANT_ID: $(entraTenantId) |
| 358 | + ENTRA_CLIENT_ID: $(entraClientId) |
| 359 | + ALLOWED_GROUP_OBJECT_IDS: $(allowedGroupObjectIds) |
| 360 | + SQL_SERVER_FQDN: $(sqlServerFqdn) |
| 361 | + SQL_DATABASE_NAME: $(sqlDatabaseName) |
| 362 | + KEY_VAULT_RESOURCE_ID: $(keyVaultResourceId) |
| 363 | + ACR_RESOURCE_ID: $(acrResourceId) |
| 364 | + ENABLE_PRIVATE_ENDPOINT: $(enablePrivateEndpoint) |
| 365 | + ENABLE_OTEL: $(enableOtel) |
| 366 | + ENV_FILE_CONTENTS: $(envFileContents) |
207 | 367 |
|
208 | 368 | - task: AzureCLI@2 |
209 | 369 | displayName: 'Health check' |
|
0 commit comments