Skip to content
Draft
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
42 changes: 35 additions & 7 deletions .github/scripts/build-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@
set -euo pipefail

# Builds a dynamic GitHub Actions matrix for run-samples.yml.
# Usage: build-matrix.sh <run_mode> [base_sha]
# Usage: build-matrix.sh <run_mode> [base_sha] [arch_filter]

# "all" runs every test; "changed" only runs tests whose watch_folders have modified files
RUN_MODE="${1:-all}"
BASE_SHA="${2:-}"
# "both" runs each test on every architecture it supports; "amd64"/"arm64" restrict it to one
ARCH_FILTER="${3:-both}"
Comment on lines +10 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM | An unrecognised arch filter yields zero jobs and a green workflow

Description: An ARCH_FILTER value that is not exactly both/amd64/arm64 matches no arch in the select, so the expansion produces an empty matrix, has_tests=false, and the whole scripts job is skipped while the workflow still reports success. I confirmed it by running the script directly:

$ ./.github/scripts/build-matrix.sh all "" "aarch64"
Run mode: all | Arch filter: aarch64 | Total tests: 34
No tests to run.        # exit 0, has_tests=false, matrix={"include":[]}

The new JOB_COUNT check is the right call for a genuine no-match, but it also makes a typo indistinguishable from the legitimate "nothing changed" outcome, so the failure is silent instead of loud. In CI the choice dropdown constrains the value, so the realistic triggers are a local invocation, a future caller, or a typo in DEFAULT_ARCH.

Rules applied: .claude/rules/azure/common/coding-style.md, "Error Handling — Handle errors explicitly at every level / Never silently swallow exceptions".

Verified this suggestion keeps both/amd64/arm64 at 48/34/14 jobs, still defaults an empty $3 to both, and exits 1 on anything else:

Suggested change
# "both" runs each test on every architecture it supports; "amd64"/"arm64" restrict it to one
ARCH_FILTER="${3:-both}"
# "both" runs each test on every architecture it supports; "amd64"/"arm64" restrict it to one
ARCH_FILTER="${3:-both}"
case "$ARCH_FILTER" in
both | amd64 | arm64) ;;
*) echo "Invalid arch filter: '$ARCH_FILTER' (expected both, amd64 or arm64)" >&2; exit 1 ;;
esac

case "$ARCH_FILTER" in
both | amd64 | arm64) ;;
*) echo "Invalid arch filter: '$ARCH_FILTER' (expected both, amd64 or arm64)" >&2; exit 1 ;;
esac

# Get JSON metadata for all tests from run-samples.sh --list
TEST_META=$(./run-samples.sh --list)
TOTAL=$(echo "$TEST_META" | jq length)
echo "Run mode: $RUN_MODE | Total tests: $TOTAL"
echo "Run mode: $RUN_MODE | Arch filter: $ARCH_FILTER | Total tests: $TOTAL"

# Changes to these files affect all tests, so any modification triggers a full run
INFRA_FILES="run-samples.sh Makefile .github/workflows/run-samples.yml .github/scripts/build-matrix.sh pyproject.toml requirements-dev.txt requirements-runtime.txt"
Expand Down Expand Up @@ -53,15 +59,37 @@ fi

# Output the matrix JSON for GitHub Actions
if [[ -z "${INDICES:-}" ]]; then
MATRIX='{"include":[]}'
else
# Convert space-separated indices to JSON array, then build the matrix object.
# Each selected test is expanded into one entry per architecture it supports, so a
# test that runs on both arches becomes two independent jobs. Tests that declare only
# amd64 depend on an amd64-only container image — see run-samples.sh for the details.
IDX_JSON=$(echo "$INDICES" | tr ' ' '\n' | jq -R 'tonumber' | jq -s '.')
MATRIX=$(echo "$TEST_META" | jq -c --argjson idx "$IDX_JSON" --arg filter "$ARCH_FILTER" \
'{include: [
$idx[] as $i | .[$i] as $test |
$test.arches[] |
select($filter == "both" or . == $filter) |
{
shard: $test.shard,
splits: $test.splits,
arch: .,
runner: (if . == "arm64" then "ubuntu-22.04-arm" else "ubuntu-22.04" end),
name: ($test.name + " [" + . + "]")
}
]}')
fi

# An arch filter can select tests that support no matching architecture, so decide
# has_tests from the expanded matrix rather than from the selected indices.
JOB_COUNT=$(echo "$MATRIX" | jq '.include | length')
if [[ "$JOB_COUNT" -eq 0 ]]; then
echo "No tests to run."
echo "has_tests=false" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT"
else
echo "has_tests=true" >> "$GITHUB_OUTPUT"
# Convert space-separated indices to JSON array, then build the matrix object
IDX_JSON=$(echo "$INDICES" | tr ' ' '\n' | jq -R 'tonumber' | jq -s '.')
MATRIX=$(echo "$TEST_META" | jq -c --argjson idx "$IDX_JSON" \
'{include: [$idx[] as $i | .[$i] | {shard, splits, name}]}')
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "Matrix:" && echo "$MATRIX" | jq .
echo "Matrix ($JOB_COUNT jobs):" && echo "$MATRIX" | jq .
fi
36 changes: 29 additions & 7 deletions .github/workflows/run-samples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ on:
- all
- changed
default: changed
arch:
description: "Architecture: 'both' runs every test on each architecture it supports"
required: false
type: choice
options:
- both
- amd64
- arm64
default: both

# Default run mode for pull_request events (change to 'changed' to only run affected tests)
# Defaults for pull_request events (change DEFAULT_RUN_MODE to 'changed' to only run affected tests)
env:
DEFAULT_RUN_MODE: changed
DEFAULT_ARCH: both

# Least-privilege default: no permissions unless a job explicitly opts in.
permissions: {}
Expand All @@ -48,14 +58,17 @@ jobs:
- name: Build dynamic matrix
id: build-matrix
env:
# Untrusted dispatch input routed via env, not interpolated into the shell (template-injection)
# Untrusted dispatch inputs routed via env, not interpolated into the shell (template-injection)
RUN_MODE_INPUT: ${{ github.event.inputs.run_mode }}
ARCH_INPUT: ${{ github.event.inputs.arch }}
run: |
# Pick run mode: manual dispatch uses the dropdown, PRs use the env default
# Pick run mode and architecture: manual dispatch uses the dropdowns, PRs use the env defaults
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RUN_MODE="${RUN_MODE_INPUT}"
ARCH="${ARCH_INPUT}"
else
RUN_MODE="${{ env.DEFAULT_RUN_MODE }}"
ARCH="${{ env.DEFAULT_ARCH }}"
fi

# In "changed" mode, determine the base commit to diff against
Expand All @@ -68,9 +81,9 @@ jobs:
fi
fi

.github/scripts/build-matrix.sh "$RUN_MODE" "$BASE_SHA"
.github/scripts/build-matrix.sh "$RUN_MODE" "$BASE_SHA" "$ARCH"

# Each test runs in its own job — one matrix entry per test from the setup job
# Each test runs in its own job — one matrix entry per test/architecture from the setup job
scripts:
name: "Test: ${{ matrix.name }}"
needs: setup
Expand All @@ -79,7 +92,9 @@ jobs:
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
runs-on: ubuntu-22.04
# ubuntu-22.04 for amd64, ubuntu-22.04-arm for arm64 — the matrix carries the label.
# GitHub-hosted arm64 runners are free for public repositories.
runs-on: ${{ matrix.runner }}

permissions:
contents: read
Expand Down Expand Up @@ -162,10 +177,14 @@ jobs:

- name: Start LocalStack
# Run the emulator in detached mode using the virtual environment.
# The readiness wait is generous because the architecture matrix roughly doubles
# the number of concurrent jobs, and the contention for Docker pulls and runner
# I/O pushed some starts past a 120s budget. A healthy emulator still returns as
# soon as it is ready, so a larger timeout costs nothing on the happy path.
run: |
source .venv/bin/activate
python -m localstack_cli.cli.main start -d
python -m localstack_cli.cli.main wait -t 120
python -m localstack_cli.cli.main wait -t 300
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }}
LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }}
Expand Down Expand Up @@ -198,6 +217,9 @@ jobs:
- name: Install MSSQL ODBC and Tools
# Required for the 'web-app-sql-database' sample which uses 'sqlcmd' to
# initialize and verify the database schema in the local emulator.
# amd64 only: the sample is backed by mcr.microsoft.com/mssql/server, which has
# no arm64 image, so it never runs on the arm64 runners.
if: matrix.arch == 'amd64'
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ export LOCALSTACK_AUTH_TOKEN=<your-token>
./run-samples.sh
```

### Architecture support (amd64 / arm64)

Some samples run **natively** on `arm64` (Apple Silicon, arm64 CI runners); the rest depend on
container images Microsoft publishes for `amd64` alone, so there is no `arm64` image to pull.

| Sample | Native amd64 | Native arm64 | Backing image |
| --- | :---: | :---: | --- |
| `function-app-*` | ✅ | ✅ | built from a multi-arch `python` / `node` / `dotnet` base |
| `web-app-custom-image` | ✅ | ✅ | the image the sample builds itself |
| `aci-blob-storage` | ✅ | ✅ | the image the sample builds itself |
| `web-app-*` (code deployment) | ✅ | emulated | `mcr.microsoft.com/oryx/<platform>` |
| `eventhubs` | ✅ | emulated | deploys a dashboard web app (Oryx, as above) |
| `servicebus/java` | ✅ | emulated | `mcr.microsoft.com/azure-app-service/java` |
| `web-app-sql-database` | ✅ | emulated | `mcr.microsoft.com/mssql/server` |

**"emulated" does not mean broken.** The emulator never pins `--platform`, so on an `arm64` host
Docker pulls the `amd64` image and runs it under whatever emulation the runtime provides. Docker
Desktop on Apple Silicon does this via Rosetta, so these samples do work on a Mac — just more
slowly. They fail only on an `arm64` host with no emulation registered, such as GitHub's
`ubuntu-*-arm` runners, which is why CI schedules only the natively-supported samples on `arm64`.

`run-samples.sh` warns when it runs one of these on an `arm64` host; set `SKIP_AMD64_ONLY=1` to skip
them instead. The authoritative list lives in `ARM64_SAMPLE_DIRS` in [run-samples.sh](./run-samples.sh);
the CI matrix is generated from it, so adding a sample there is all that is needed to have it
covered natively on both architectures.

> **Note:** Function Apps on `arm64` were fixed by
> [localstack-pro#8102](https://github.com/localstack/localstack-pro/pull/8102) and work on the
> current `localstack/localstack-azure:latest`. If you pin an older emulator image, expect every
> Function App deployment to fail with a misleading `500 ... No route to host`: those images
> unpack `amd64` Azure Functions Core Tools into an `arm64` image, so the host process cannot run.

### Troubleshooting: Line Endings
If you encounter errors like `invalid option name` or `: command not found` when running on Linux/WSL, it's likely due to Windows-style line endings (CRLF). You can fix this by running:
```bash
Expand Down
105 changes: 101 additions & 4 deletions run-samples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,82 @@ BICEP_SAMPLES=(
ALL_SAMPLES=("${SAMPLES[@]}" "${TERRAFORM_SAMPLES[@]}" "${BICEP_SAMPLES[@]}")
TOTAL=${#ALL_SAMPLES[@]}

# 1c. Architecture support
# Samples listed here run *natively* on both amd64 and arm64 (Apple Silicon, arm64 CI
# runners). For everything else, the container image the emulator starts is published by
# Microsoft for amd64 alone — there is no arm64 manifest:
# - `az webapp create --runtime ...` (code deployment) runs the app on
# mcr.microsoft.com/oryx/<platform>, which is amd64-only. That rules out every
# web-app-* sample, plus eventhubs (it deploys a dashboard web app).
# - Java App Service uses mcr.microsoft.com/azure-app-service/java — amd64-only.
# - SQL Database is backed by mcr.microsoft.com/mssql/server — amd64-only.
# The samples below avoid all of those: Function Apps get an image built from a
# multi-arch base (arm64 support added in localstack-pro#8102), and the custom-image
# Web App and ACI samples run an image the sample itself builds. Their Cosmos DB,
# Service Bus, Storage and Front Door dependencies all publish arm64 manifests.
#
# "amd64-only" means *not native* — not "cannot run". The emulator never pins
# --platform, so on an arm64 host Docker pulls the amd64 manifest and runs it under
# whatever emulation the runtime provides. Docker Desktop on Apple Silicon emulates it
# via Rosetta, which is why these samples do work on a Mac, just more slowly. They fail
# on an arm64 host with no emulation registered, such as GitHub's ubuntu-*-arm runners,
# so CI schedules only the natively-supported samples below on arm64.
#
# LocalStack's own test suite draws the same line: after localstack-pro#8102, Web App
# tests that only assert SCM/deployment records run on arm64, while the ones that
# exercise a *running* Oryx app (test_deploy_zip_that_accesses_storage,
# test_deploy_zip_without_basic_auth) are still marked @only_on_amd64.
ARM64_SAMPLE_DIRS=(
"samples/aci-blob-storage/python"
"samples/function-app-front-door/python"
"samples/function-app-managed-identity/python"
"samples/function-app-service-bus/dotnet"
"samples/function-app-storage-http/dotnet"
"samples/web-app-custom-image/python"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW | A stale ARM64_SAMPLE_DIRS entry silently drops arm64 coverage

Description: supports_arm64 compares entries by exact string, so renaming a sample directory without updating this second list silently demotes it to amd64-only, and CI stays green. I simulated one typo'd entry (.../dotnet.../csharp) and the run went from 48 to 45 jobs (14 → 11 arm64 tests) with exit code 0 and no diagnostic. Since arch support is now declared away from the sample arrays, a cheap consistency guard keeps the two from drifting.

Rules applied: .claude/rules/azure/common/coding-style.md, "DRY — Avoid copy-paste implementation drift"; .claude/rules/azure/scripts/coding-style.md, "Reuse captured values; don't hardcode or repeat them".

Verified: passes unchanged on the current list, and fails with the message below when an entry no longer matches a registered sample.

Suggested change
)
)
# Fail loudly if an entry no longer matches a registered sample: a rename would otherwise
# silently drop that sample's arm64 coverage while CI stays green.
for _arm64_dir in "${ARM64_SAMPLE_DIRS[@]}"; do
if ! printf '%s\n' "${ALL_SAMPLES[@]}" | cut -d'|' -f1 | sed -E 's#/(terraform|bicep)$##' | grep -qxF "$_arm64_dir"; then
echo >&2 "ARM64_SAMPLE_DIRS entry '$_arm64_dir' matches no registered sample - fix the path or remove the entry."
exit 1
fi
done


# Fail loudly if an entry no longer matches a registered sample: a rename would otherwise
# silently drop that sample's arm64 coverage while CI stays green.
for _arm64_dir in "${ARM64_SAMPLE_DIRS[@]}"; do
if ! printf '%s\n' "${ALL_SAMPLES[@]}" | cut -d'|' -f1 | sed -E 's#/(terraform|bicep)$##' | grep -qxF "$_arm64_dir"; then
echo >&2 "ARM64_SAMPLE_DIRS entry '$_arm64_dir' matches no registered sample - fix the path or remove the entry."
exit 1
fi
done

# Architecture support is a property of the sample itself, not of the deployment method,
# so strip the /terraform or /bicep suffix before matching against ARM64_SAMPLE_DIRS.
supports_arm64() {
local path="$1" family dir
case "$path" in
*/terraform | */bicep) family=$(dirname "$path") ;;
*) family="$path" ;;
esac
for dir in "${ARM64_SAMPLE_DIRS[@]}"; do
[[ "$dir" == "$family" ]] && return 0
done
return 1
}

# Normalise `uname -m` to the architecture names used above. Anything not recognisably
# arm64 is reported as amd64, so an exotic host (armv7l, ppc64le, ...) takes the amd64
# path and gets no arm64 warning: it errs toward "run it and let the deployment fail"
# rather than skipping silently.
host_arch() {
case "$(uname -m)" in
aarch64 | arm64) echo "arm64" ;;
x86_64 | amd64) echo "amd64" ;;
*)
echo >&2 "Note: unrecognised architecture '$(uname -m)'; treating it as amd64."
echo "amd64"
;;
esac
}

# 2. Handle --list flag: output JSON metadata for CI matrix generation (no tools required)
# Each entry has: shard (1-based index), splits (total count), name, and watch_folders.
# CI uses watch_folders to detect which tests are affected by changed files.
# Each entry has: shard (1-based index), splits (total count), name, watch_folders,
# and arches. CI uses watch_folders to detect which tests are affected by changed
# files, and arches to decide which architectures to run the test on.
if [[ "${1:-}" == "--list" ]]; then
echo "["
for (( i=0; i<TOTAL; i++ )); do
Expand All @@ -98,8 +171,15 @@ if [[ "${1:-}" == "--list" ]]; then
fi

folders=$(printf '"%s",' "${watch[@]}")
printf ' {"shard":%d,"splits":%d,"name":"%s","watch_folders":[%s]}' \
$((i+1)) "$TOTAL" "$name" "${folders%,}"

if supports_arm64 "$path"; then
arches='"amd64","arm64"'
else
arches='"amd64"'
fi

printf ' {"shard":%d,"splits":%d,"name":"%s","watch_folders":[%s],"arches":[%s]}' \
$((i+1)) "$TOTAL" "$name" "${folders%,}" "$arches"
(( i < TOTAL-1 )) && echo "," || echo ""
done
echo "]"
Expand Down Expand Up @@ -164,8 +244,11 @@ if [ "$SHARD" -eq "$SPLITS" ]; then
COUNT=$(( TOTAL - START ))
fi

HOST_ARCH=$(host_arch)

echo "Running samples shard $SHARD of $SPLITS (index $START, count $COUNT)"
echo "Total samples (scripts + terraform + bicep): $TOTAL"
echo "Host architecture: $HOST_ARCH"

# 7. Run Samples — deploy each test, then run its validation if defined
for (( i=START; i<START+COUNT; i++ )); do
Expand All @@ -175,6 +258,20 @@ for (( i=START; i<START+COUNT; i++ )); do
echo "Testing Sample: $path"
echo "============================================================"

# Warn rather than skip: on an arm64 host these still run under amd64 emulation
# (Rosetta on Docker Desktop), just slowly. Set SKIP_AMD64_ONLY=1 to skip them
# instead — useful on an arm64 host with no emulation registered, where they would
# otherwise fail with an opaque error deep inside a deployment.
if [[ "$HOST_ARCH" == "arm64" ]] && ! supports_arm64 "$path"; then
if [[ "${SKIP_AMD64_ONLY:-0}" == "1" ]]; then
echo "Skipped: $path needs an amd64-only container image (SKIP_AMD64_ONLY=1)."
continue
fi
echo "Note: $path needs an amd64-only container image; on arm64 it runs under"
echo " emulation (slower) and fails outright if the runtime cannot emulate"
echo " amd64. Set SKIP_AMD64_ONLY=1 to skip these instead."
fi

pushd "$path" > /dev/null

echo "Deploying..."
Expand Down