Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions .azuredevops/pipelines/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,57 @@ stages:
ACR_NAME="$(azd env get-value ACR_NAME --environment "$AZD_ENV_NAME")"
RG="$(azd env get-value AZURE_RESOURCE_GROUP --environment "$AZD_ENV_NAME")"

# Pin az CLI to the subscription azd just provisioned into. Without
# this, az may default to a different active subscription for the SP,
# causing az acr build to return ParentResourceNotFound even though
# the registry exists.
az account set --subscription "$AZURE_SUBSCRIPTION_ID"

# Wait for ACR ARM propagation. Right after `azd provision` completes,
# the registry resource exists but the ContainerRegistry RP can return
# ParentResourceNotFound from data-plane actions like
# listBuildSourceUploadUrl for up to ~60s while the resource graph
# propagates. Poll `az acr show` until it succeeds (or 5 min cap).
echo "Waiting for ACR '$ACR_NAME' in '$RG' to become queryable..."
for attempt in $(seq 1 30); do
if az acr show --name "$ACR_NAME" --resource-group "$RG" --query id -o tsv >/dev/null 2>&1; then
echo " ACR queryable after ${attempt} attempt(s)."
break
fi
if [[ "$attempt" == "30" ]]; then
echo "##vso[task.logissue type=error]ACR '$ACR_NAME' still not queryable after 5 minutes."
exit 1
fi
sleep 10
done

# Server-side build+push - no Docker daemon, no docker login (WIF identity active).
# The SemVer is baked into the binary (/p:Version) and image (OCI label +
# APP_VERSION env) via the VERSION build arg, and pushed as an extra
# human-readable :$SEMVER tag alongside the immutable :$TAG (git SHA).
az acr build --registry "$ACR_NAME" --build-arg VERSION="$SEMVER" \
--image "mapaq-api:$TAG" --image "mapaq-api:$SEMVER" --file src/Mapaq.Api/Dockerfile .
az acr build --registry "$ACR_NAME" --build-arg VERSION="$SEMVER" \
--image "mapaq-web:$TAG" --image "mapaq-web:$SEMVER" --file src/Mapaq.Web/Dockerfile .
# Wrap acr build in a small retry to absorb the residual RP propagation
# window (ParentResourceNotFound) immediately after provision.
acr_build() {
local image_base="$1" dockerfile="$2"
for attempt in 1 2 3 4 5; do
if az acr build \
--registry "$ACR_NAME" \
--resource-group "$RG" \
--build-arg VERSION="$SEMVER" \
--image "${image_base}:$TAG" \
--image "${image_base}:$SEMVER" \
--file "$dockerfile" .; then
return 0
fi
echo "##vso[task.logissue type=warning]az acr build (${image_base}) attempt ${attempt} failed; retrying in 20s..."
sleep 20
done
echo "##vso[task.logissue type=error]az acr build (${image_base}) failed after 5 attempts."
return 1
}

acr_build mapaq-api src/Mapaq.Api/Dockerfile
acr_build mapaq-web src/Mapaq.Web/Dockerfile

# Force both sites to pull the now-present image.
API_SITE="$(az webapp list -g "$RG" --query "[?starts_with(name,'mapaq-api-')].name | [0]" -o tsv)"
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Dependency review on PRs — equivalent of the ADO AdvancedSecurity-Dependency-
# Scanning task in .azuredevops/pipelines/adv-sec.yml.
#
# CodeQL parity with ADO's AdvancedSecurity-Codeql tasks is provided by GitHub's
# repository-level **default code scanning** (see Settings → Code security and
# analysis → Code scanning). The default setup runs CodeQL across every detected
# language and is mutually exclusive with an "advanced" workflow file — adding a
# custom codeql.yml here causes uploads to be rejected with:
# "CodeQL analyses from advanced configurations cannot be processed when the
# default setup is enabled"
# Keep CodeQL under the default setup and only layer dependency review on top.
name: dependency-review

on:
pull_request:
branches: [main]

permissions:
contents: read
pull-requests: write

jobs:
review:
name: Dependency review (PR diff)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
61 changes: 57 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ permissions:
# contents: write lets the deploy job push the release git tag (v<SemVer>).
contents: write

# Serialize deploys per target environment, mirroring the ADO pipeline's
# `lockBehavior: runLatest` on the workshop-dev environment. With
# cancel-in-progress: false, an in-flight deploy finishes and any newer queued
# run replaces older pending runs in the queue — preventing the DeploymentActive
# race when back-to-back CI completions both start `azd provision` and clash on
# the same ARM deployment names (pe-<token>, etc.) inside rg-<env>.
concurrency:
group: deploy-${{ inputs.azureEnvName || vars.AZURE_ENV_NAME || 'dev-001' }}
cancel-in-progress: false

jobs:
deploy:
name: azd up
Expand Down Expand Up @@ -98,14 +108,57 @@ jobs:
ACR_NAME="$(azd env get-value ACR_NAME)"
RG="$(azd env get-value AZURE_RESOURCE_GROUP)"

# Pin az CLI to the same subscription azd just provisioned into. Without
# this, az may default to a different active subscription (or none) for
# the SP, and az acr build returns ParentResourceNotFound even though
# the registry exists.
az account set --subscription "$AZURE_SUBSCRIPTION_ID"

# Wait for ACR ARM propagation. Right after `azd provision` completes,
# the registry resource exists but the ContainerRegistry RP can return
# ParentResourceNotFound from data-plane actions like
# listBuildSourceUploadUrl for up to ~60s while the resource graph
# propagates. Poll `az acr show` until it succeeds (or 5 min cap).
echo "Waiting for ACR '$ACR_NAME' in '$RG' to become queryable..."
for attempt in $(seq 1 30); do
if az acr show --name "$ACR_NAME" --resource-group "$RG" --query id -o tsv >/dev/null 2>&1; then
echo " ACR queryable after ${attempt} attempt(s)."
break
fi
if [[ "$attempt" == "30" ]]; then
echo "::error::ACR '$ACR_NAME' still not queryable after 5 minutes."
exit 1
fi
sleep 10
done

# Server-side build+push - no Docker daemon, no docker login (OIDC identity active).
# The SemVer is baked into the binary (/p:Version) and image (OCI label + APP_VERSION
# env) via the VERSION build arg, and pushed as an extra human-readable :$SEMVER tag
# alongside the immutable :$TAG (git SHA) that the sites actually run.
az acr build --registry "$ACR_NAME" --build-arg VERSION="$SEMVER" \
--image "mapaq-api:$TAG" --image "mapaq-api:$SEMVER" --file src/Mapaq.Api/Dockerfile .
az acr build --registry "$ACR_NAME" --build-arg VERSION="$SEMVER" \
--image "mapaq-web:$TAG" --image "mapaq-web:$SEMVER" --file src/Mapaq.Web/Dockerfile .
# Wrap acr build in a small retry to absorb the residual RP propagation window
# (ParentResourceNotFound on listBuildSourceUploadUrl) immediately after provision.
acr_build() {
local image_base="$1" dockerfile="$2"
for attempt in 1 2 3 4 5; do
if az acr build \
--registry "$ACR_NAME" \
--resource-group "$RG" \
--build-arg VERSION="$SEMVER" \
--image "${image_base}:$TAG" \
--image "${image_base}:$SEMVER" \
--file "$dockerfile" .; then
return 0
fi
echo "::warning::az acr build (${image_base}) attempt ${attempt} failed; retrying in 20s..."
sleep 20
done
echo "::error::az acr build (${image_base}) failed after 5 attempts."
return 1
}

acr_build mapaq-api src/Mapaq.Api/Dockerfile
acr_build mapaq-web src/Mapaq.Web/Dockerfile

# Force both sites to pull the now-present image.
api_site="$(az webapp list -g "$RG" --query "[?starts_with(name,'mapaq-api-')].name | [0]" -o tsv)"
Expand Down
202 changes: 202 additions & 0 deletions .github/workflows/load-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Locust load test against the deployed Mapaq.Api / Mapaq.Web App Services.
# Mirrors .azuredevops/pipelines/load-test.yml.
#
# Defaults to the workshop-dev environment (rg-dev-001) and discovers the public
# URLs from the resource group at run time, so attendees do not have to memorize
# the resourceToken-suffixed hostnames. Override apiUrl / webUrl (or the
# azureEnvName input) at dispatch time to point at a different environment.
#
# Outputs:
# * GitHub Checks tab — one testcase per endpoint via JUnit XML (EnricoMi publish).
# * Workflow artifact `load-test-report` — full Locust HTML + CSV bundle.
# A run is marked failed when any endpoint had HTTP failures or its p95 latency
# exceeded p95ThresholdMs (when failOnLoadErrors=true).
name: load-test

on:
workflow_dispatch:
inputs:
azureEnvName:
description: "azd environment name (resource group rg-<name>)"
required: false
type: string
default: "dev-001"
apiUrl:
description: 'API base URL (use "auto" to discover from rg-<azureEnvName>)'
required: false
type: string
default: "auto"
webUrl:
description: 'Web base URL (use "auto" to discover from rg-<azureEnvName>)'
required: false
type: string
default: "auto"
users:
description: "Peak concurrent users"
required: false
type: number
default: 25
spawnRate:
description: "Users to spawn per second"
required: false
type: number
default: 5
duration:
description: "Run duration (Locust syntax, e.g. 30s, 2m, 5m)"
required: false
type: string
default: "2m"
p95ThresholdMs:
description: "p95 latency budget per endpoint (ms). 0 disables the latency gate."
required: false
type: number
default: 1500
failOnLoadErrors:
description: "Fail the pipeline when any endpoint exceeds the budget or has HTTP errors"
required: false
type: boolean
default: true

permissions:
id-token: write
contents: read
checks: write
pull-requests: write

jobs:
load_test:
name: Run Locust against deployed App Services
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: workshop-dev
env:
PYTHON_VERSION: "3.12"
REPORT_DIR: ${{ github.workspace }}/load-test-report
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: pip install -r tests/load/requirements.txt
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install -r tests/load/requirements.txt

- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Resolve API / Web URLs from rg-${{ inputs.azureEnvName }}
id: resolve
env:
AZ_ENV: ${{ inputs.azureEnvName }}
API_URL_PARAM: ${{ inputs.apiUrl }}
WEB_URL_PARAM: ${{ inputs.webUrl }}
run: |
set -euo pipefail
RG="rg-${AZ_ENV}"

resolve_site() {
local prefix="$1"
local hostname
hostname="$(az webapp list \
--resource-group "$RG" \
--query "[?starts_with(name, '${prefix}-')].defaultHostName | [0]" \
-o tsv)"
if [[ -z "$hostname" ]]; then
echo ""
else
echo "https://${hostname}"
fi
}

if [[ -n "$API_URL_PARAM" && "$API_URL_PARAM" != "auto" ]]; then
RESOLVED_API="$API_URL_PARAM"
else
RESOLVED_API="$(resolve_site mapaq-api)"
fi
if [[ -n "$WEB_URL_PARAM" && "$WEB_URL_PARAM" != "auto" ]]; then
RESOLVED_WEB="$WEB_URL_PARAM"
else
RESOLVED_WEB="$(resolve_site mapaq-web)"
fi

if [[ -z "$RESOLVED_API" || -z "$RESOLVED_WEB" ]]; then
echo "::error::Could not resolve API/Web URLs from $RG. Pass apiUrl/webUrl explicitly. api='$RESOLVED_API' web='$RESOLVED_WEB'"
exit 1
fi

echo "Resolved API -> $RESOLVED_API"
echo "Resolved Web -> $RESOLVED_WEB"
echo "MAPAQ_API_HOST=$RESOLVED_API" >> "$GITHUB_ENV"
echo "MAPAQ_WEB_HOST=$RESOLVED_WEB" >> "$GITHUB_ENV"

- name: Run Locust headless
env:
LOCUST_VERIFY_SSL: "1"
run: |
set -uo pipefail
mkdir -p "$REPORT_DIR"

echo "Targets:"
echo " api -> $MAPAQ_API_HOST"
echo " web -> $MAPAQ_WEB_HOST"

# Pre-flight against the API; do not fail here — Locust surfaces persistent outages.
if curl -fsS --max-time 10 "$MAPAQ_API_HOST/healthz" -o /dev/null; then
echo "API /healthz reachable."
else
echo "::warning::API /healthz did not respond before the run started."
fi

# Locust's runner overwrites every user_class.host with --host when
# passed, so we omit --host and let MapaqApiUser / MapaqWebUser pin
# themselves via MAPAQ_API_HOST / MAPAQ_WEB_HOST (see locustfile.py).
# Always exit 0 — the JUnit publish step decides build outcome per-endpoint.
python -m locust \
-f tests/load/locustfile.py \
--headless \
--users ${{ inputs.users }} \
--spawn-rate ${{ inputs.spawnRate }} \
--run-time ${{ inputs.duration }} \
--html "$REPORT_DIR/report.html" \
--csv "$REPORT_DIR/stats" \
--only-summary
LOCUST_EXIT=$?
echo "Locust exit code: $LOCUST_EXIT (per-endpoint pass/fail decided by the JUnit publish step)"
exit 0

- name: Convert Locust stats to JUnit
if: always()
run: |
set -euo pipefail
python tests/load/locust_stats_to_junit.py \
--stats-prefix "$REPORT_DIR/stats" \
--output "$REPORT_DIR/junit.xml" \
--suite-name "Mapaq Load Test (${{ inputs.azureEnvName }})" \
--p95-threshold-ms ${{ inputs.p95ThresholdMs }}

- name: Publish JUnit results to Checks tab
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: ${{ env.REPORT_DIR }}/junit.xml
check_name: "Mapaq Load Test (${{ inputs.azureEnvName }})"
comment_mode: off
fail_on: ${{ inputs.failOnLoadErrors && 'test failures' || 'nothing' }}

- name: Upload HTML + CSV report bundle
if: always()
uses: actions/upload-artifact@v4
with:
name: load-test-report
path: ${{ env.REPORT_DIR }}
Loading
Loading