From c9ce49c097a330f6dbaef9181a6e195e1b98dc88 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 16:50:20 +0000 Subject: [PATCH 01/21] debug: add workflow to investigate flaky layer ordering tests Add a manually-triggered GitHub Actions workflow that runs test_download_two_layers 5 times with debug instrumentation to diagnose why layers sometimes return 'Layer1' instead of 'Layer2' on Docker (but not Finch). Instrumentation logs: - Layer ordering in download_all() input/output - Generated Dockerfile ADD command sequence - Tarball construction order - Image build vs reuse decisions --- .github/scripts/instrument_layer_debug.py | 119 +++++++++ .github/workflows/debug-layer-tests.yml | 283 ++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 .github/scripts/instrument_layer_debug.py create mode 100644 .github/workflows/debug-layer-tests.yml diff --git a/.github/scripts/instrument_layer_debug.py b/.github/scripts/instrument_layer_debug.py new file mode 100644 index 00000000000..dd6ef08cd0a --- /dev/null +++ b/.github/scripts/instrument_layer_debug.py @@ -0,0 +1,119 @@ +""" +Instrument SAM CLI source files with debug logging for layer ordering investigation. +This script patches layer_downloader.py, lambda_image.py, and tar.py to emit +DEBUG_LAYER_ORDER log lines that help diagnose non-deterministic layer ordering. +""" + +import sys + + +def patch_file(filepath, replacements): + with open(filepath, "r") as f: + content = f.read() + + for old, new in replacements: + if old not in content: + print(f"WARNING: Could not find patch target in {filepath}:") + print(f" Looking for: {old[:80]}...") + sys.exit(1) + content = content.replace(old, new) + + with open(filepath, "w") as f: + f.write(content) + print(f"Patched {filepath}") + + +# --- Patch layer_downloader.py --- +patch_file("samcli/local/layers/layer_downloader.py", [ + ( + # Add debug logging to download_all + ' layer_dirs = []\n' + ' for layer in layers:\n' + ' layer_dirs.append(self.download(layer, force))\n' + '\n' + ' return layer_dirs', + + ' LOG.warning("DEBUG_LAYER_ORDER: download_all called with %d layers", len(layers))\n' + ' for i, layer in enumerate(layers):\n' + ' LOG.warning("DEBUG_LAYER_ORDER: input layer[%d] arn=%s name=%s", i, layer.arn, layer.name)\n' + '\n' + ' layer_dirs = []\n' + ' for layer in layers:\n' + ' layer_dirs.append(self.download(layer, force))\n' + '\n' + ' LOG.warning("DEBUG_LAYER_ORDER: download_all returning %d layers", len(layer_dirs))\n' + ' for i, layer in enumerate(layer_dirs):\n' + ' LOG.warning("DEBUG_LAYER_ORDER: output layer[%d] name=%s codeuri=%s", i, layer.name, layer.codeuri)\n' + '\n' + ' return layer_dirs' + ), +]) + +# --- Patch lambda_image.py --- +patch_file("samcli/local/docker/lambda_image.py", [ + ( + # Add debug logging to _generate_dockerfile + ' for layer in layers:\n' + ' dockerfile_content = dockerfile_content + f"ADD {layer.name} {LambdaImage._LAYERS_DIR}\\n"\n' + ' return dockerfile_content', + + ' for layer in layers:\n' + ' dockerfile_content = dockerfile_content + f"ADD {layer.name} {LambdaImage._LAYERS_DIR}\\n"\n' + ' LOG.warning("DEBUG_LAYER_ORDER: Generated Dockerfile:\\n%s", dockerfile_content)\n' + ' return dockerfile_content' + ), + ( + # Add debug logging to _build_image tar_paths + ' for layer in layers:\n' + ' tar_paths[layer.codeuri] = "/" + layer.name\n' + '\n' + ' # Use shared tar filter for Windows compatibility', + + ' for layer in layers:\n' + ' tar_paths[layer.codeuri] = "/" + layer.name\n' + ' LOG.warning("DEBUG_LAYER_ORDER: tar_paths entry: %s -> /%s", layer.codeuri, layer.name)\n' + '\n' + ' LOG.warning("DEBUG_LAYER_ORDER: tar_paths keys order: %s", list(tar_paths.keys()))\n' + ' LOG.warning("DEBUG_LAYER_ORDER: tar_paths values order: %s", list(tar_paths.values()))\n' + '\n' + ' # Use shared tar filter for Windows compatibility' + ), + ( + # Add debug logging to build method decision + ' if (\n' + ' self.force_image_build\n' + ' or image_not_found\n' + ' or any(layer.is_defined_within_template for layer in downloaded_layers)\n' + ' or not runtime\n' + ' ):', + + ' LOG.warning(\n' + ' "DEBUG_LAYER_ORDER: Build decision - force=%s image_not_found=%s"\n' + ' " any_template_layers=%s runtime=%s rapid_image=%s",\n' + ' self.force_image_build, image_not_found,\n' + ' any(layer.is_defined_within_template for layer in downloaded_layers),\n' + ' runtime, rapid_image)\n' + ' if (\n' + ' self.force_image_build\n' + ' or image_not_found\n' + ' or any(layer.is_defined_within_template for layer in downloaded_layers)\n' + ' or not runtime\n' + ' ):' + ), +]) + +# --- Patch tar.py --- +patch_file("samcli/lib/utils/tar.py", [ + ( + ' with tarfile.open(fileobj=tarballfile, mode=mode, dereference=do_dereferece) as archive:\n' + ' for path_on_system, path_in_tarball in tar_paths.items():\n' + ' archive.add(path_on_system, arcname=path_in_tarball, filter=tar_filter)', + + ' with tarfile.open(fileobj=tarballfile, mode=mode, dereference=do_dereferece) as archive:\n' + ' for path_on_system, path_in_tarball in tar_paths.items():\n' + ' LOG.warning("DEBUG_LAYER_ORDER: Adding to tarball: %s -> %s", path_on_system, path_in_tarball)\n' + ' archive.add(path_on_system, arcname=path_in_tarball, filter=tar_filter)' + ), +]) + +print("All files instrumented successfully.") diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml new file mode 100644 index 00000000000..798825ee1e2 --- /dev/null +++ b/.github/workflows/debug-layer-tests.yml @@ -0,0 +1,283 @@ +name: Debug Layer Order Tests + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_DEFAULT_REGION: us-east-1 + SAM_CLI_DEV: 1 + SAM_CLI_TELEMETRY: 0 + SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true + UV_PYTHON: python3.11 + CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} + ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} + +jobs: + debug-layer-tests: + if: github.repository == 'aws/aws-sam-cli' + name: Debug Layer Order (${{ matrix.container_runtime }}) + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + container_runtime: [docker, finch] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Free up disk space + run: | + nohup sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL & + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + docker system prune -af --volumes || true + else + nohup docker system prune -af --volumes || true & + fi + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} + aws-region: us-east-1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Setup Finch runtime + if: matrix.container_runtime == 'finch' + run: | + sudo systemctl stop docker || true + sudo systemctl stop docker.socket || true + sudo systemctl disable docker || true + sudo systemctl disable docker.socket || true + + for i in {1..3}; do + if curl -fsSL https://artifact.runfinch.com/deb/GPG_KEY.pub | sudo gpg --dearmor -o /usr/share/keyrings/runfinch-finch-archive-keyring.gpg; then + break + fi + sleep 10 + done + + echo 'deb [signed-by=/usr/share/keyrings/runfinch-finch-archive-keyring.gpg arch=amd64] https://artifact.runfinch.com/deb noble main' | sudo tee /etc/apt/sources.list.d/runfinch-finch.list + sudo apt update + sudo apt install -y runfinch-finch + sudo systemctl enable --now finch + sudo systemctl enable --now finch-buildkit + sleep 3 + sudo chmod 666 /var/run/finch.sock + + for i in {1..12}; do + if sudo finch info >/dev/null 2>&1; then + break + fi + sleep 5 + done + + sudo mkdir -p /run/buildkit-finch /run/buildkit-default /run/buildkit + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-finch/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-default/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit/buildkitd.sock + sudo chmod 666 /var/lib/finch/buildkit/buildkitd.sock + sudo chmod 666 /run/buildkit-*/buildkitd.sock + sudo finch run --privileged --rm tonistiigi/binfmt:master --install all + sudo finch info + + - name: Setup Docker runtime + if: matrix.container_runtime == 'docker' + run: | + docker info + docker version + + - name: Setup QEMU for ARM64 emulation + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + sudo finch run --rm --privileged multiarch/qemu-user-static --reset -p yes + else + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + fi + + - name: Initialize project + run: | + export CONTAINER_RUNTIME=${{ matrix.container_runtime }} + make init + + - name: Get testing resources and credentials + run: | + test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) + + if [ $? -ne 0 ]; then + test_env_var=$(python3.11 tests/get_testing_resources.py) + if [ $? -ne 0 ]; then + echo "Failed to acquire credentials or test resources." + exit 1 + fi + fi + + echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV + + TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") + TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") + TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") + TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") + + echo "::add-mask::$TEST_ACCESS_KEY_ID" + echo "::add-mask::$TEST_SECRET_ACCESS_KEY" + echo "::add-mask::$TEST_SESSION_TOKEN" + echo "::add-mask::$TEST_TASK_TOKEN" + + echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV + echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV + + echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV + echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV + + - name: Login to Public ECR + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + aws ecr-public get-login-password --region us-east-1 | sudo finch login --username AWS --password-stdin public.ecr.aws + else + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + fi + + - name: Add debug instrumentation + run: python3.11 .github/scripts/instrument_layer_debug.py + + - name: Verify instrumentation + run: grep -c "DEBUG_LAYER_ORDER" samcli/local/layers/layer_downloader.py samcli/local/docker/lambda_image.py samcli/lib/utils/tar.py + + - name: Run layer order debug tests + run: | + export CONTAINER_RUNTIME=${{ matrix.container_runtime }} + + echo "============================================" + echo "=== Layer zip contents ===" + echo "============================================" + python3.11 -c " + import zipfile, os + base = 'tests/integration/testdata/invoke/layer_zips' + for zf in ['layer1.zip', 'layer2.zip']: + path = os.path.join(base, zf) + print(f'=== {zf} ===') + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + print(f' {name}') + if name.endswith('.py'): + print(z.read(name).decode()) + " + + PASS_COUNT=0 + FAIL_COUNT=0 + + for i in $(seq 1 5); do + echo "" + echo "============================================" + echo "=== Iteration $i of 5 ===" + echo "============================================" + + rm -rf ~/integ_layer_cache + + echo "--- Cleaning up container images ---" + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + sudo finch images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep samcli | while read img; do + echo "Removing: $img" + sudo finch rmi "$img" 2>/dev/null || true + done + else + docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep samcli | while read img; do + echo "Removing: $img" + docker rmi "$img" 2>/dev/null || true + done + echo "--- Pruning Docker build cache ---" + docker builder prune -af 2>/dev/null || true + fi + + rm -rf /tmp/*.downloading.lock /tmp/*.downloading.status /tmp/*.building.lock /tmp/*.building.status 2>/dev/null || true + + echo "--- Running test_download_two_layers ---" + pytest -vv -s --tb=long --log-cli-level=DEBUG \ + tests/integration/local/invoke/test_integrations_cli.py \ + -k "test_download_two_layers" \ + --json-report --json-report-file=TEST_REPORT-debug-layer-iter${i}-${{ matrix.container_runtime }}.json \ + 2>&1 | tee test_output_iter${i}.log + + TEST_EXIT=$? + + echo "" + echo "--- Post-test: layer cache python files ---" + find ~/integ_layer_cache -type f -name "*.py" 2>/dev/null -exec echo "=== {} ===" \; -exec cat {} \; + + echo "" + echo "--- Post-test: lock/status files ---" + find ~/integ_layer_cache -name "*.lock" -o -name "*.status" 2>/dev/null -exec echo "=== {} ===" \; -exec cat {} \; + + echo "" + echo "--- Post-test: container images ---" + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + sudo finch images 2>/dev/null || true + else + docker images 2>/dev/null | head -20 || true + fi + + if [ $TEST_EXIT -eq 0 ]; then + PASS_COUNT=$((PASS_COUNT + 1)) + echo "=== Iteration $i: PASSED ===" + else + FAIL_COUNT=$((FAIL_COUNT + 1)) + echo "=== Iteration $i: FAILED ===" + fi + + echo "" + echo "--- DEBUG_LAYER_ORDER log lines ---" + grep "DEBUG_LAYER_ORDER" test_output_iter${i}.log || echo "No DEBUG_LAYER_ORDER lines found" + + done + + echo "" + echo "============================================" + echo "=== SUMMARY ===" + echo "============================================" + echo "Passed: $PASS_COUNT / 5" + echo "Failed: $FAIL_COUNT / 5" + + if [ $FAIL_COUNT -gt 0 ]; then + echo "Some iterations failed - layer ordering is non-deterministic" + exit 1 + fi + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: debug-layer-logs-${{ matrix.container_runtime }} + path: | + test_output_iter*.log + TEST_REPORT-debug-layer-*.json + + - name: Reset test account + if: always() + run: | + export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID + export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY + export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN + + aws lambda invoke \ + --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ + --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ + ./lambda-output.txt \ + --region us-west-2 \ + --cli-binary-format raw-in-base64-out + + cat ./lambda-output.txt From 899b4f93cf0b0b7b34f2a25e6adde025fe5942dd Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 17:03:15 +0000 Subject: [PATCH 02/21] debug: simplify workflow to run original test suite with instrumentation Instead of running isolated iterations, run the exact same pytest command as the CI local-invoke suite with added debug logging. This reproduces the real test ordering and cross-test interactions. Enhanced instrumentation now also captures: - Per-layer download decisions (cached vs fresh) - Extracted layer file contents after download - Docker build stream output (cache hits show as CACHED) --- .github/scripts/instrument_layer_debug.py | 111 +++++++++-- .github/workflows/debug-layer-tests.yml | 218 ++++++---------------- 2 files changed, 155 insertions(+), 174 deletions(-) diff --git a/.github/scripts/instrument_layer_debug.py b/.github/scripts/instrument_layer_debug.py index dd6ef08cd0a..b2a61e4ba6e 100644 --- a/.github/scripts/instrument_layer_debug.py +++ b/.github/scripts/instrument_layer_debug.py @@ -11,22 +11,33 @@ def patch_file(filepath, replacements): with open(filepath, "r") as f: content = f.read() - for old, new in replacements: + for i, (old, new) in enumerate(replacements): if old not in content: - print(f"WARNING: Could not find patch target in {filepath}:") - print(f" Looking for: {old[:80]}...") + print(f"FAIL: Could not find patch target #{i} in {filepath}") + # Show first 120 chars of what we're looking for + print(f" Looking for: {old[:120]!r}...") + # Try to find partial match + first_line = old.split("\n")[0].strip() + if first_line in content: + idx = content.index(first_line) + print(f" First line found at char {idx}, context:") + print(repr(content[idx:idx+300])) + else: + print(f" First line not found: {first_line!r}") sys.exit(1) - content = content.replace(old, new) + content = content.replace(old, new, 1) with open(filepath, "w") as f: f.write(content) - print(f"Patched {filepath}") + print(f"Patched {filepath} ({len(replacements)} patches)") -# --- Patch layer_downloader.py --- +# ============================================================================= +# Patch 1: samcli/local/layers/layer_downloader.py +# ============================================================================= patch_file("samcli/local/layers/layer_downloader.py", [ + # 1a: Add debug logging to download_all ( - # Add debug logging to download_all ' layer_dirs = []\n' ' for layer in layers:\n' ' layer_dirs.append(self.download(layer, force))\n' @@ -47,12 +58,69 @@ def patch_file(filepath, replacements): '\n' ' return layer_dirs' ), + # 1b: Add debug logging to download method - cache check and download path + ( + ' layer_path = Path(self.layer_cache).resolve().joinpath(layer.name)\n' + ' is_layer_downloaded = self._is_layer_cached(layer_path)\n' + ' layer.codeuri = str(layer_path)\n' + '\n' + ' if is_layer_downloaded and not force:\n' + ' LOG.info("%s is already cached. Skipping download", layer.arn)\n' + ' return layer', + + ' layer_path = Path(self.layer_cache).resolve().joinpath(layer.name)\n' + ' is_layer_downloaded = self._is_layer_cached(layer_path)\n' + ' layer.codeuri = str(layer_path)\n' + ' LOG.warning("DEBUG_LAYER_ORDER: download() layer=%s path=%s cached=%s force=%s",\n' + ' layer.name, layer_path, is_layer_downloaded, force)\n' + '\n' + ' if is_layer_downloaded and not force:\n' + ' LOG.warning("DEBUG_LAYER_ORDER: SKIPPING download for %s (cached)", layer.arn)\n' + ' return layer' + ), + # 1c: Add debug logging after successful download with content verification + ( + ' unzip_from_uri(\n' + ' layer_zip_uri,\n' + ' layer_zip_path,\n' + ' unzip_output_dir=layer.codeuri,\n' + ' progressbar_label="Downloading {}".format(layer.layer_arn),\n' + ' )\n' + '\n' + ' download_lock.release_lock(success=True)\n' + ' return layer', + + ' unzip_from_uri(\n' + ' layer_zip_uri,\n' + ' layer_zip_path,\n' + ' unzip_output_dir=layer.codeuri,\n' + ' progressbar_label="Downloading {}".format(layer.layer_arn),\n' + ' )\n' + '\n' + ' # Debug: verify extracted content\n' + ' import os as _os\n' + ' for _root, _dirs, _files in _os.walk(layer.codeuri):\n' + ' for _f in _files:\n' + ' _fpath = _os.path.join(_root, _f)\n' + ' if _f.endswith(".py"):\n' + ' try:\n' + ' with open(_fpath) as _fh:\n' + ' LOG.warning("DEBUG_LAYER_ORDER: extracted %s content: %s",\n' + ' _fpath, _fh.read().strip())\n' + ' except Exception:\n' + ' pass\n' + '\n' + ' download_lock.release_lock(success=True)\n' + ' return layer' + ), ]) -# --- Patch lambda_image.py --- +# ============================================================================= +# Patch 2: samcli/local/docker/lambda_image.py +# ============================================================================= patch_file("samcli/local/docker/lambda_image.py", [ + # 2a: Add debug logging to _generate_dockerfile ( - # Add debug logging to _generate_dockerfile ' for layer in layers:\n' ' dockerfile_content = dockerfile_content + f"ADD {layer.name} {LambdaImage._LAYERS_DIR}\\n"\n' ' return dockerfile_content', @@ -62,8 +130,8 @@ def patch_file(filepath, replacements): ' LOG.warning("DEBUG_LAYER_ORDER: Generated Dockerfile:\\n%s", dockerfile_content)\n' ' return dockerfile_content' ), + # 2b: Add debug logging to _build_image tar_paths ( - # Add debug logging to _build_image tar_paths ' for layer in layers:\n' ' tar_paths[layer.codeuri] = "/" + layer.name\n' '\n' @@ -78,8 +146,8 @@ def patch_file(filepath, replacements): '\n' ' # Use shared tar filter for Windows compatibility' ), + # 2c: Add debug logging to build method decision ( - # Add debug logging to build method decision ' if (\n' ' self.force_image_build\n' ' or image_not_found\n' @@ -100,9 +168,26 @@ def patch_file(filepath, replacements): ' or not runtime\n' ' ):' ), + # 2d: Log Docker build stream output to capture cache hits (Using -> CACHED) + ( + ' # Process stream messages\n' + ' if "stream" in log:\n' + ' stream_msg = log["stream"].strip()\n' + ' if stream_msg:\n' + ' LOG.debug(f"Build stream: {stream_msg}")', + + ' # Process stream messages\n' + ' if "stream" in log:\n' + ' stream_msg = log["stream"].strip()\n' + ' if stream_msg:\n' + ' LOG.warning("DEBUG_LAYER_ORDER: Build stream: %s", stream_msg)\n' + ' LOG.debug(f"Build stream: {stream_msg}")' + ), ]) -# --- Patch tar.py --- +# ============================================================================= +# Patch 3: samcli/lib/utils/tar.py +# ============================================================================= patch_file("samcli/lib/utils/tar.py", [ ( ' with tarfile.open(fileobj=tarballfile, mode=mode, dereference=do_dereferece) as archive:\n' @@ -116,4 +201,4 @@ def patch_file(filepath, replacements): ), ]) -print("All files instrumented successfully.") +print("\nAll files instrumented successfully.") diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml index 798825ee1e2..992e3102e83 100644 --- a/.github/workflows/debug-layer-tests.yml +++ b/.github/workflows/debug-layer-tests.yml @@ -12,6 +12,10 @@ env: SAM_CLI_DEV: 1 SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + NODE_VERSION: "22.21.1" + AWS_S3: "AWS_S3_TESTING" + AWS_ECR: "AWS_ECR_TESTING" + CARGO_LAMBDA_VERSION: "v0.17.1" NOSE_PARAMETERIZED_NO_WARN: 1 BY_CANARY: true UV_PYTHON: python3.11 @@ -21,12 +25,8 @@ env: jobs: debug-layer-tests: if: github.repository == 'aws/aws-sam-cli' - name: Debug Layer Order (${{ matrix.container_runtime }}) + name: Debug Layer Order (docker) runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - container_runtime: [docker, finch] steps: - name: Checkout code @@ -35,11 +35,7 @@ jobs: - name: Free up disk space run: | nohup sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL & - if [ "${{ matrix.container_runtime }}" = "finch" ]; then - docker system prune -af --volumes || true - else - nohup docker system prune -af --volumes || true & - fi + nohup docker system prune -af --volumes || true & - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v5 @@ -50,65 +46,40 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.11" - - - name: Setup Finch runtime - if: matrix.container_runtime == 'finch' - run: | - sudo systemctl stop docker || true - sudo systemctl stop docker.socket || true - sudo systemctl disable docker || true - sudo systemctl disable docker.socket || true - - for i in {1..3}; do - if curl -fsSL https://artifact.runfinch.com/deb/GPG_KEY.pub | sudo gpg --dearmor -o /usr/share/keyrings/runfinch-finch-archive-keyring.gpg; then - break - fi - sleep 10 - done - - echo 'deb [signed-by=/usr/share/keyrings/runfinch-finch-archive-keyring.gpg arch=amd64] https://artifact.runfinch.com/deb noble main' | sudo tee /etc/apt/sources.list.d/runfinch-finch.list - sudo apt update - sudo apt install -y runfinch-finch - sudo systemctl enable --now finch - sudo systemctl enable --now finch-buildkit - sleep 3 - sudo chmod 666 /var/run/finch.sock - - for i in {1..12}; do - if sudo finch info >/dev/null 2>&1; then - break - fi - sleep 5 - done - - sudo mkdir -p /run/buildkit-finch /run/buildkit-default /run/buildkit - sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-finch/buildkitd.sock - sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-default/buildkitd.sock - sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit/buildkitd.sock - sudo chmod 666 /var/lib/finch/buildkit/buildkitd.sock - sudo chmod 666 /run/buildkit-*/buildkitd.sock - sudo finch run --privileged --rm tonistiigi/binfmt:master --install all - sudo finch info + python-version: | + 3.11 + 3.9 + 3.10 + 3.12 + 3.13 + 3.14 - name: Setup Docker runtime - if: matrix.container_runtime == 'docker' run: | docker info docker version - name: Setup QEMU for ARM64 emulation + run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + + - name: Install Terraform run: | - if [ "${{ matrix.container_runtime }}" = "finch" ]; then - sudo finch run --rm --privileged multiarch/qemu-user-static --reset -p yes - else - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - fi + for i in {1..3}; do + TER_VER=$(curl -s https://api.github.com/repos/hashicorp/terraform/releases/latest | grep tag_name | cut -d: -f2 | tr -d \"\,\v | awk '{$1=$1};1') + if [ -n "$TER_VER" ]; then + if wget https://releases.hashicorp.com/terraform/${TER_VER}/terraform_${TER_VER}_linux_amd64.zip -P /tmp; then + sudo unzip -d /opt/terraform /tmp/terraform_${TER_VER}_linux_amd64.zip + sudo mv /opt/terraform/terraform /usr/local/bin/ + terraform -version + break + fi + fi + echo "Terraform installation attempt $i failed, retrying..." + sleep 5 + done - name: Initialize project - run: | - export CONTAINER_RUNTIME=${{ matrix.container_runtime }} - make init + run: make init - name: Get testing resources and credentials run: | @@ -143,14 +114,13 @@ jobs: echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV + echo "AWS_KMS_KEY=$(echo "$test_env_var" | jq -j ".TestKMSKeyArn")" >> $GITHUB_ENV + echo "AWS_SIGNING_PROFILE_NAME=$(echo "$test_env_var" | jq -j ".TestSigningProfileName")" >> $GITHUB_ENV + echo "AWS_SIGNING_PROFILE_VERSION_ARN=$(echo "$test_env_var" | jq -j ".TestSigningProfileARN")" >> $GITHUB_ENV - name: Login to Public ECR run: | - if [ "${{ matrix.container_runtime }}" = "finch" ]; then - aws ecr-public get-login-password --region us-east-1 | sudo finch login --username AWS --password-stdin public.ecr.aws - else - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws - fi + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws - name: Add debug instrumentation run: python3.11 .github/scripts/instrument_layer_debug.py @@ -158,113 +128,39 @@ jobs: - name: Verify instrumentation run: grep -c "DEBUG_LAYER_ORDER" samcli/local/layers/layer_downloader.py samcli/local/docker/lambda_image.py samcli/lib/utils/tar.py - - name: Run layer order debug tests + - name: Run original integration tests with debug logging run: | - export CONTAINER_RUNTIME=${{ matrix.container_runtime }} - - echo "============================================" - echo "=== Layer zip contents ===" - echo "============================================" - python3.11 -c " - import zipfile, os - base = 'tests/integration/testdata/invoke/layer_zips' - for zf in ['layer1.zip', 'layer2.zip']: - path = os.path.join(base, zf) - print(f'=== {zf} ===') - with zipfile.ZipFile(path) as z: - for name in z.namelist(): - print(f' {name}') - if name.endswith('.py'): - print(z.read(name).decode()) - " - - PASS_COUNT=0 - FAIL_COUNT=0 - - for i in $(seq 1 5); do - echo "" - echo "============================================" - echo "=== Iteration $i of 5 ===" - echo "============================================" - - rm -rf ~/integ_layer_cache - - echo "--- Cleaning up container images ---" - if [ "${{ matrix.container_runtime }}" = "finch" ]; then - sudo finch images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep samcli | while read img; do - echo "Removing: $img" - sudo finch rmi "$img" 2>/dev/null || true - done - else - docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep samcli | while read img; do - echo "Removing: $img" - docker rmi "$img" 2>/dev/null || true - done - echo "--- Pruning Docker build cache ---" - docker builder prune -af 2>/dev/null || true - fi - - rm -rf /tmp/*.downloading.lock /tmp/*.downloading.status /tmp/*.building.lock /tmp/*.building.status 2>/dev/null || true - - echo "--- Running test_download_two_layers ---" - pytest -vv -s --tb=long --log-cli-level=DEBUG \ - tests/integration/local/invoke/test_integrations_cli.py \ - -k "test_download_two_layers" \ - --json-report --json-report-file=TEST_REPORT-debug-layer-iter${i}-${{ matrix.container_runtime }}.json \ - 2>&1 | tee test_output_iter${i}.log - - TEST_EXIT=$? - - echo "" - echo "--- Post-test: layer cache python files ---" - find ~/integ_layer_cache -type f -name "*.py" 2>/dev/null -exec echo "=== {} ===" \; -exec cat {} \; - - echo "" - echo "--- Post-test: lock/status files ---" - find ~/integ_layer_cache -name "*.lock" -o -name "*.status" 2>/dev/null -exec echo "=== {} ===" \; -exec cat {} \; - - echo "" - echo "--- Post-test: container images ---" - if [ "${{ matrix.container_runtime }}" = "finch" ]; then - sudo finch images 2>/dev/null || true - else - docker images 2>/dev/null | head -20 || true - fi - - if [ $TEST_EXIT -eq 0 ]; then - PASS_COUNT=$((PASS_COUNT + 1)) - echo "=== Iteration $i: PASSED ===" - else - FAIL_COUNT=$((FAIL_COUNT + 1)) - echo "=== Iteration $i: FAILED ===" - fi - - echo "" - echo "--- DEBUG_LAYER_ORDER log lines ---" - grep "DEBUG_LAYER_ORDER" test_output_iter${i}.log || echo "No DEBUG_LAYER_ORDER lines found" - - done + pytest -vv --reruns 3 \ + tests/integration/local/invoke \ + tests/integration/local/generate_event \ + --ignore tests/integration/local/invoke/test_invoke_durable.py \ + --log-cli-level=DEBUG \ + --json-report \ + --json-report-file=TEST_REPORT-integration-local-invoke-docker.json \ + 2>&1 | tee full_test_output.log + + - name: Extract debug layer order lines + if: always() + run: | + echo "=== DEBUG_LAYER_ORDER lines ===" + grep "DEBUG_LAYER_ORDER" full_test_output.log || echo "No DEBUG_LAYER_ORDER lines found" echo "" - echo "============================================" - echo "=== SUMMARY ===" - echo "============================================" - echo "Passed: $PASS_COUNT / 5" - echo "Failed: $FAIL_COUNT / 5" + echo "=== Test results summary ===" + grep -E "PASSED|FAILED|ERROR" full_test_output.log | grep -i "layer" || true - if [ $FAIL_COUNT -gt 0 ]; then - echo "Some iterations failed - layer ordering is non-deterministic" - exit 1 - fi + echo "" + echo "=== Docker images after tests ===" + docker images | head -30 || true - name: Upload test logs if: always() uses: actions/upload-artifact@v6 with: - name: debug-layer-logs-${{ matrix.container_runtime }} + name: debug-layer-logs path: | - test_output_iter*.log - TEST_REPORT-debug-layer-*.json + full_test_output.log + TEST_REPORT-integration-local-invoke-docker.json - name: Reset test account if: always() From 03164241fc0eb3921c9d151691482922cbf3a554 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 17:05:28 +0000 Subject: [PATCH 03/21] debug: narrow tests to TestLayerVersion class only Run just the TestLayerVersion class instead of the full local-invoke suite. This covers the failing test_download_two_layers plus the other tests in the class that may cause cross-test contamination. --- .github/workflows/debug-layer-tests.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml index 992e3102e83..3ee9876e7b9 100644 --- a/.github/workflows/debug-layer-tests.yml +++ b/.github/workflows/debug-layer-tests.yml @@ -62,22 +62,6 @@ jobs: - name: Setup QEMU for ARM64 emulation run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - name: Install Terraform - run: | - for i in {1..3}; do - TER_VER=$(curl -s https://api.github.com/repos/hashicorp/terraform/releases/latest | grep tag_name | cut -d: -f2 | tr -d \"\,\v | awk '{$1=$1};1') - if [ -n "$TER_VER" ]; then - if wget https://releases.hashicorp.com/terraform/${TER_VER}/terraform_${TER_VER}_linux_amd64.zip -P /tmp; then - sudo unzip -d /opt/terraform /tmp/terraform_${TER_VER}_linux_amd64.zip - sudo mv /opt/terraform/terraform /usr/local/bin/ - terraform -version - break - fi - fi - echo "Terraform installation attempt $i failed, retrying..." - sleep 5 - done - - name: Initialize project run: make init @@ -128,12 +112,11 @@ jobs: - name: Verify instrumentation run: grep -c "DEBUG_LAYER_ORDER" samcli/local/layers/layer_downloader.py samcli/local/docker/lambda_image.py samcli/lib/utils/tar.py - - name: Run original integration tests with debug logging + - name: Run TestLayerVersion tests with debug logging run: | pytest -vv --reruns 3 \ - tests/integration/local/invoke \ - tests/integration/local/generate_event \ - --ignore tests/integration/local/invoke/test_invoke_durable.py \ + tests/integration/local/invoke/test_integrations_cli.py \ + -k "TestLayerVersion" \ --log-cli-level=DEBUG \ --json-report \ --json-report-file=TEST_REPORT-integration-local-invoke-docker.json \ From 648dd76151d20eaf13244519d3ec942197ffbd25 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 21:00:18 +0000 Subject: [PATCH 04/21] debug: remove repo guard so workflow runs on fork --- .github/workflows/debug-layer-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml index 3ee9876e7b9..ae99170ca4c 100644 --- a/.github/workflows/debug-layer-tests.yml +++ b/.github/workflows/debug-layer-tests.yml @@ -24,7 +24,6 @@ env: jobs: debug-layer-tests: - if: github.repository == 'aws/aws-sam-cli' name: Debug Layer Order (docker) runs-on: ubuntu-22.04 From 71bad0db54f0dff9256a92bbc67b4fa5df0abe40 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 21:57:22 +0000 Subject: [PATCH 05/21] debug: use direct OIDC auth, remove credential vending system --- .github/workflows/debug-layer-tests.yml | 67 +------------------------ 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml index ae99170ca4c..58289f451cf 100644 --- a/.github/workflows/debug-layer-tests.yml +++ b/.github/workflows/debug-layer-tests.yml @@ -8,19 +8,11 @@ permissions: contents: read env: - AWS_DEFAULT_REGION: us-east-1 SAM_CLI_DEV: 1 SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 - NODE_VERSION: "22.21.1" - AWS_S3: "AWS_S3_TESTING" - AWS_ECR: "AWS_ECR_TESTING" - CARGO_LAMBDA_VERSION: "v0.17.1" NOSE_PARAMETERIZED_NO_WARN: 1 - BY_CANARY: true UV_PYTHON: python3.11 - CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} - ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} jobs: debug-layer-tests: @@ -40,7 +32,7 @@ jobs: uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} - aws-region: us-east-1 + aws-region: us-west-2 - name: Set up Python uses: actions/setup-python@v6 @@ -48,10 +40,6 @@ jobs: python-version: | 3.11 3.9 - 3.10 - 3.12 - 3.13 - 3.14 - name: Setup Docker runtime run: | @@ -64,43 +52,6 @@ jobs: - name: Initialize project run: make init - - name: Get testing resources and credentials - run: | - test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) - - if [ $? -ne 0 ]; then - test_env_var=$(python3.11 tests/get_testing_resources.py) - if [ $? -ne 0 ]; then - echo "Failed to acquire credentials or test resources." - exit 1 - fi - fi - - echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV - - TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") - TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") - TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") - TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") - - echo "::add-mask::$TEST_ACCESS_KEY_ID" - echo "::add-mask::$TEST_SECRET_ACCESS_KEY" - echo "::add-mask::$TEST_SESSION_TOKEN" - echo "::add-mask::$TEST_TASK_TOKEN" - - echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV - echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV - - echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV - echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV - echo "AWS_KMS_KEY=$(echo "$test_env_var" | jq -j ".TestKMSKeyArn")" >> $GITHUB_ENV - echo "AWS_SIGNING_PROFILE_NAME=$(echo "$test_env_var" | jq -j ".TestSigningProfileName")" >> $GITHUB_ENV - echo "AWS_SIGNING_PROFILE_VERSION_ARN=$(echo "$test_env_var" | jq -j ".TestSigningProfileARN")" >> $GITHUB_ENV - - name: Login to Public ECR run: | aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws @@ -143,19 +94,3 @@ jobs: path: | full_test_output.log TEST_REPORT-integration-local-invoke-docker.json - - - name: Reset test account - if: always() - run: | - export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY - export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN - - aws lambda invoke \ - --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ - --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ - ./lambda-output.txt \ - --region us-west-2 \ - --cli-binary-format raw-in-base64-out - - cat ./lambda-output.txt From d7225c697bdf79d9d844f461e80f325cd9b883ee Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 11 Feb 2026 22:01:04 +0000 Subject: [PATCH 06/21] debug: add BY_CANARY=true to prevent layer tests from being skipped --- .github/workflows/debug-layer-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/debug-layer-tests.yml b/.github/workflows/debug-layer-tests.yml index 58289f451cf..9899cb670d2 100644 --- a/.github/workflows/debug-layer-tests.yml +++ b/.github/workflows/debug-layer-tests.yml @@ -12,6 +12,7 @@ env: SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true UV_PYTHON: python3.11 jobs: From b51bdf655295e1ce93334541885bb00b452e91d7 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 12 Feb 2026 19:36:22 -0800 Subject: [PATCH 07/21] fix: remove fallback in count_running_containers that causes flaky warm container tests * Update notify-slack.yml to add sleep (#8646) * fix: use tag prefix matching to clean up samcli/lambda-* images (#8647) Docker's images.list(name='samcli/lambda') does exact repository matching and won't match repositories like 'samcli/lambda-python'. This caused stale images to persist across parameterized test classes, leading to flaky test_download_two_layers failures where Layer2 should overwrite Layer1 but the image was never rebuilt. Fix by: 1. Adding _cleanup_samcli_images() that lists all images and filters by 'samcli/lambda-' tag prefix 2. Using this method in both tearDown and tearDownClass 3. Fixing the same pattern in TestLayerVersionThatDoNotCreateCache * fix: remove fallback in count_running_containers that causes flaky warm container tests The count_running_containers method had a fallback that returned the count of ALL SAM CLI containers when MODE env var filtering found no matches. This caused AssertionError: 3 != 2 when stale containers from other tests were present. Now it strictly counts only containers matching this test's unique MODE UUID and uses exact string matching. * fix: add AWS_DEFAULT_REGION and use -k pattern for parameterized tests * fix: add BY_CANARY=true to enable Docker tests on CI * fix: exclude RemoteLayers tests that need AWS credentials --------- Co-authored-by: seshubaws <116689586+seshubaws@users.noreply.github.com> --- .github/workflows/notify-slack.yml | 5 +- .../workflows/test-warm-containers-fix.yml | 44 +++++++++++++++++ .../local/invoke/test_integrations_cli.py | 47 +++++++++++++------ .../local/start_api/test_start_api.py | 15 ++---- .../local/start_lambda/test_start_lambda.py | 15 ++---- 5 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/test-warm-containers-fix.yml diff --git a/.github/workflows/notify-slack.yml b/.github/workflows/notify-slack.yml index c87ec70eefe..d42971a4f57 100644 --- a/.github/workflows/notify-slack.yml +++ b/.github/workflows/notify-slack.yml @@ -12,8 +12,11 @@ jobs: permissions: contents: read steps: + - name: Wait for label to be applied + run: sleep 10s + shell: bash - name: Send External PR Notification - if: github.event_name == 'pull_request' && github.event.label.name == 'pr/external' + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'pr/external') uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2 env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/test-warm-containers-fix.yml b/.github/workflows/test-warm-containers-fix.yml new file mode 100644 index 00000000000..aa14c6c162e --- /dev/null +++ b/.github/workflows/test-warm-containers-fix.yml @@ -0,0 +1,44 @@ +name: Test Warm Containers Fix + +on: + pull_request: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + +env: + AWS_DEFAULT_REGION: us-east-1 + SAM_CLI_DEV: 1 + SAM_CLI_TELEMETRY: 0 + SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + BY_CANARY: true + +jobs: + test-warm-containers: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install SAM CLI in dev mode + run: pip install -e ".[dev]" + + - name: Run warm containers tests + run: | + pytest -vv --reruns 3 \ + -k "TestWarmContainers and not RemoteLayers" \ + tests/integration/local/start_api/test_start_api.py \ + tests/integration/local/start_lambda/test_start_lambda.py \ + --json-report --json-report-file=test-report.json + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: test-report.json diff --git a/tests/integration/local/invoke/test_integrations_cli.py b/tests/integration/local/invoke/test_integrations_cli.py index 2971ab00773..e2b51aa9eb1 100644 --- a/tests/integration/local/invoke/test_integrations_cli.py +++ b/tests/integration/local/invoke/test_integrations_cli.py @@ -803,13 +803,27 @@ class TestLayerVersionBase(InvokeIntegBase): def setUp(self): self.layer_cache = Path().home().joinpath("integ_layer_cache") + @staticmethod + def _cleanup_samcli_images(docker_client): + """Remove all samcli/lambda-* images. + + Docker's images.list(name="samcli/lambda") does exact repository matching + and won't match repositories like "samcli/lambda-python". We list all images + and filter by tag prefix instead. + """ + try: + all_images = docker_client.images.list() + for image in all_images: + for tag in image.tags: + if tag.startswith("samcli/lambda-"): + docker_client.remove_image_safely(image.id, force=True) + break + except Exception: + pass + def tearDown(self): docker_client = get_validated_container_client() - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method for runtime-aware image cleanup - docker_client.remove_image_safely(image.id, force=True) - + self._cleanup_samcli_images(docker_client) shutil.rmtree(str(self.layer_cache), ignore_errors=True) @classmethod @@ -823,10 +837,7 @@ def tearDownClass(cls): cls.layer_utils.delete_layers() # Added to handle the case where ^C failed the test due to invalid cleanup of layers docker_client = get_validated_container_client() - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method for runtime-aware image cleanup - docker_client.remove_image_safely(image.id, force=True) + cls._cleanup_samcli_images(docker_client) integ_layer_cache_dir = Path().home().joinpath("integ_layer_cache") if integ_layer_cache_dir.exists(): shutil.rmtree(str(integ_layer_cache_dir)) @@ -1069,12 +1080,18 @@ def setUp(self): def tearDown(self): docker_client = get_validated_container_client() - # Use strategy pattern method for runtime-aware image cleanup - # This handles both Docker and Finch cleanup strategies automatically - samcli_images = docker_client.images.list(name="samcli/lambda") - for image in samcli_images: - # Use strategy pattern method that handles runtime-specific cleanup logic - docker_client.remove_image_safely(image.id, force=True) + # Use the same prefix-based cleanup as TestLayerVersionBase. + # Docker's images.list(name="samcli/lambda") does exact repository matching + # and won't match "samcli/lambda-python" etc. + try: + all_images = docker_client.images.list() + for image in all_images: + for tag in image.tags: + if tag.startswith("samcli/lambda-"): + docker_client.remove_image_safely(image.id, force=True) + break + except Exception: + pass def test_layer_does_not_exist(self): self.layer_utils.upsert_layer(LayerUtils.generate_layer_name(), "LayerOneArn", "layer1.zip") diff --git a/tests/integration/local/start_api/test_start_api.py b/tests/integration/local/start_api/test_start_api.py index e53b2540126..5ddd7bc8a70 100644 --- a/tests/integration/local/start_api/test_start_api.py +++ b/tests/integration/local/start_api/test_start_api.py @@ -2220,35 +2220,26 @@ def setUp(self): def count_running_containers(self): """Count containers created by this test using Docker client directly.""" - # Use Docker client to find containers with SAM CLI labels try: - # Get running containers with SAM CLI lambda container label sam_containers = self.docker_client.containers.list( all=False, filters={"label": "sam.cli.container.type=lambda"} ) - # Filter by our test's mode environment variable if possible test_containers = [] for container in sam_containers: try: container.reload() env_vars = container.attrs.get("Config", {}).get("Env", []) for env_var in env_vars: - if env_var.startswith("MODE=") and self.mode_env_variable in env_var: + if env_var == f"MODE={self.mode_env_variable}": test_containers.append(container) break except Exception: continue - # If we found containers with our mode variable, return that count - if test_containers: - return len(test_containers) + return len(test_containers) - # Otherwise, return all SAM containers (fallback) - return len(sam_containers) - - except Exception as e: - # If we can't access Docker client, fall back to 0 + except Exception: return 0 def _parse_container_ids_from_output(self): diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index c2765887b52..3039ee2a82c 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -403,35 +403,26 @@ def setUp(self): def count_running_containers(self): """Count containers created by this test using Docker client directly.""" - # Use Docker client to find containers with SAM CLI labels try: - # Get running containers with SAM CLI lambda container label sam_containers = self.docker_client.containers.list( all=False, filters={"label": "sam.cli.container.type=lambda"} ) - # Filter by our test's mode environment variable if possible test_containers = [] for container in sam_containers: try: container.reload() env_vars = container.attrs.get("Config", {}).get("Env", []) for env_var in env_vars: - if env_var.startswith("MODE=") and self.mode_env_variable in env_var: + if env_var == f"MODE={self.mode_env_variable}": test_containers.append(container) break except Exception: continue - # If we found containers with our mode variable, return that count - if test_containers: - return len(test_containers) + return len(test_containers) - # Otherwise, return all SAM containers (fallback) - return len(sam_containers) - - except Exception as e: - # If we can't access Docker client, fall back to 0 + except Exception: return 0 def _parse_container_ids_from_output(self): From 42be3c77e6676fcf64bc9faedb7fa54370580da3 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 01:35:05 +0000 Subject: [PATCH 08/21] fix: replace symlinks with dirs for Finch container mount compatibility When build_in_source is used with Node.js, the build directory contains a node_modules symlink. During local invoke, SAM CLI resolves this symlink and creates an additional bind mount. Docker tolerates creating a mountpoint over a symlink, but Finch (containerd/runc) fails with 'not a directory'. This fix temporarily replaces symlinks with empty directories before container creation, then restores them afterward. This ensures: - Finch/runc gets a valid directory mountpoint - Docker continues to work as before - Repeated invocations work because symlinks are restored - Host filesystem is left unchanged even if container creation fails --- .../workflows/verify-finch-symlink-fix.yml | 188 ++++++++++++++++++ samcli/local/docker/container.py | 41 ++++ 2 files changed, 229 insertions(+) create mode 100644 .github/workflows/verify-finch-symlink-fix.yml diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml new file mode 100644 index 00000000000..f4c93665975 --- /dev/null +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -0,0 +1,188 @@ +name: Verify Finch Symlink Mount Fix + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_DEFAULT_REGION: us-east-1 + SAM_CLI_DEV: 1 + SAM_CLI_TELEMETRY: 0 + SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true + UV_PYTHON: python3.11 + CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} + ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} + +jobs: + verify-symlink-fix: + name: build-in-source invoke (${{ matrix.container_runtime }}) + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + container_runtime: [docker, finch] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Free up disk space + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + docker system prune -af --volumes || true + else + nohup docker system prune -af --volumes || true & + fi + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} + aws-region: us-east-1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Setup Finch runtime + if: matrix.container_runtime == 'finch' + run: | + echo "=== Initializing Finch runtime ===" + sudo systemctl stop docker || true + sudo systemctl stop docker.socket || true + sudo systemctl disable docker || true + sudo systemctl disable docker.socket || true + + for i in {1..3}; do + if curl -fsSL https://artifact.runfinch.com/deb/GPG_KEY.pub | sudo gpg --dearmor -o /usr/share/keyrings/runfinch-finch-archive-keyring.gpg; then + break + fi + sleep 10 + done + + echo 'deb [signed-by=/usr/share/keyrings/runfinch-finch-archive-keyring.gpg arch=amd64] https://artifact.runfinch.com/deb noble main' | sudo tee /etc/apt/sources.list.d/runfinch-finch.list + sudo apt update + sudo apt install -y runfinch-finch + sudo systemctl enable --now finch + sudo systemctl enable --now finch-buildkit + sleep 3 + sudo chmod 666 /var/run/finch.sock + + for i in {1..12}; do + if sudo finch info >/dev/null 2>&1; then + break + fi + sleep 5 + done + + sudo mkdir -p /run/buildkit-finch /run/buildkit-default /run/buildkit + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-finch/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit-default/buildkitd.sock + sudo ln -sf /var/lib/finch/buildkit/buildkitd.sock /run/buildkit/buildkitd.sock + sudo chmod 666 /var/lib/finch/buildkit/buildkitd.sock + sudo chmod 666 /run/buildkit-*/buildkitd.sock + sudo finch run --privileged --rm tonistiigi/binfmt:master --install all + + sudo finch info + sudo finch version + + - name: Setup Docker runtime + if: matrix.container_runtime == 'docker' + run: | + echo "=== Initializing Docker runtime ===" + docker info + docker version + + - name: Initialize project + run: | + export CONTAINER_RUNTIME=${{ matrix.container_runtime }} + make init + + - name: Get testing resources and credentials + run: | + test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) + + if [ $? -ne 0 ]; then + echo "First attempt with skip_role_deletion failed, trying without parameter..." + test_env_var=$(python3.11 tests/get_testing_resources.py) + + if [ $? -ne 0 ]; then + echo "get_testing_resources failed. Failed to acquire credentials or test resources." + exit 1 + fi + fi + + echo "::add-mask::$AWS_ACCESS_KEY_ID" + echo "::add-mask::$AWS_SECRET_ACCESS_KEY" + echo "::add-mask::$AWS_SESSION_TOKEN" + echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV + + TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") + TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") + TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") + TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") + + echo "::add-mask::$TEST_ACCESS_KEY_ID" + echo "::add-mask::$TEST_SECRET_ACCESS_KEY" + echo "::add-mask::$TEST_SESSION_TOKEN" + echo "::add-mask::$TEST_TASK_TOKEN" + + echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV + echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV + + echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV + echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV + + - name: Login to Public ECR + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + aws ecr-public get-login-password --region us-east-1 | sudo finch login --username AWS --password-stdin public.ecr.aws + else + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + fi + + - name: Run build-in-source invoke tests + run: | + if [ "${{ matrix.container_runtime }}" = "finch" ]; then + export CONTAINER_RUNTIME="finch" + fi + + pytest -vv --reruns 3 tests/integration/local/invoke/test_invoke_build_in_source.py \ + --json-report \ + --json-report-file=TEST_REPORT-build-in-source-${{ matrix.container_runtime }}.json + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results-build-in-source-${{ matrix.container_runtime }} + path: TEST_REPORT-*.json + + - name: Reset test account + if: always() + run: | + export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID + export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY + export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN + + aws lambda invoke \ + --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ + --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ + ./lambda-output.txt \ + --region us-west-2 \ + --cli-binary-format raw-in-base64-out + + cat ./lambda-output.txt diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index 6ef37b6b1e4..d73b7dda7b5 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -160,6 +160,7 @@ def __init__( self._host_tmp_dir = host_tmp_dir self._mount_symlinks = mount_symlinks self.debug_options = debug_options + self._replaced_symlinks: list = [] # Track symlinks replaced with dirs for restore after container creation # Container-level concurrency management self._concurrency_semaphore: Optional[threading.Semaphore] = None # Controls concurrent Lambda executions self._max_concurrency: int = 1 # Default to 1 for normal functions @@ -286,6 +287,11 @@ def create(self, context): raise DockerContainerCreationFailedException( f"Container creation failed: {ex.explanation}, check template for potential issue" ) + finally: + # Restore any symlinks that were temporarily replaced with directories + # for container mount compatibility. This ensures the host filesystem + # remains unchanged for subsequent invocations. + self._restore_mapped_symlinks() self.id = real_container.id # Output container ID for test parsing @@ -347,6 +353,25 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]: "mode": mount_mode, } + # Replace the symlink with an empty directory so that container runtimes + # (e.g. Finch/containerd) can create a valid mountpoint. Without this, + # runc fails with "not a directory" when it encounters a symlink at the + # mount target path inside the container's rootfs. + # We record the original symlink target so it can be restored after + # container creation (see _restore_mapped_symlinks). + try: + symlink_path = file.path + symlink_target = os.readlink(symlink_path) + os.remove(symlink_path) + os.makedirs(symlink_path, exist_ok=True) + self._replaced_symlinks.append((symlink_path, symlink_target)) + LOG.debug( + "Replaced symlink at %s with empty directory for container mount compatibility", + symlink_path, + ) + except OSError: + LOG.debug("Failed to replace symlink at %s with directory", file.path, exc_info=True) + LOG.info( "Mounting resolved symlink (%s -> %s) as %s:%s, inside runtime container" % (file.path, host_resolved_path, container_full_path, mount_mode) @@ -354,6 +379,22 @@ def _create_mapped_symlink_files(self) -> Dict[str, Dict[str, str]]: return additional_volumes + def _restore_mapped_symlinks(self): + """ + Restores symlinks that were temporarily replaced with empty directories + by _create_mapped_symlink_files. This is called after container creation + so the host filesystem is left in its original state for subsequent invocations. + """ + for symlink_path, symlink_target in self._replaced_symlinks: + try: + if os.path.isdir(symlink_path) and not os.path.islink(symlink_path): + os.rmdir(symlink_path) + os.symlink(symlink_target, symlink_path) + LOG.debug("Restored symlink at %s -> %s", symlink_path, symlink_target) + except OSError: + LOG.debug("Failed to restore symlink at %s", symlink_path, exc_info=True) + self._replaced_symlinks.clear() + def stop(self, timeout=3): """ Stop a container, with a given number of seconds between sending SIGTERM and SIGKILL. From 3db71fe8b687caed660b715f572b86dfdbe23937 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 01:37:16 +0000 Subject: [PATCH 09/21] simplify verification workflow: use OIDC credentials directly --- .../workflows/verify-finch-symlink-fix.yml | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml index f4c93665975..e549dee2973 100644 --- a/.github/workflows/verify-finch-symlink-fix.yml +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -13,10 +13,7 @@ env: SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 NOSE_PARAMETERIZED_NO_WARN: 1 - BY_CANARY: true UV_PYTHON: python3.11 - CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} - ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} jobs: verify-symlink-fix: @@ -107,45 +104,6 @@ jobs: export CONTAINER_RUNTIME=${{ matrix.container_runtime }} make init - - name: Get testing resources and credentials - run: | - test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) - - if [ $? -ne 0 ]; then - echo "First attempt with skip_role_deletion failed, trying without parameter..." - test_env_var=$(python3.11 tests/get_testing_resources.py) - - if [ $? -ne 0 ]; then - echo "get_testing_resources failed. Failed to acquire credentials or test resources." - exit 1 - fi - fi - - echo "::add-mask::$AWS_ACCESS_KEY_ID" - echo "::add-mask::$AWS_SECRET_ACCESS_KEY" - echo "::add-mask::$AWS_SESSION_TOKEN" - echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV - - TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") - TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") - TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") - TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") - - echo "::add-mask::$TEST_ACCESS_KEY_ID" - echo "::add-mask::$TEST_SECRET_ACCESS_KEY" - echo "::add-mask::$TEST_SESSION_TOKEN" - echo "::add-mask::$TEST_TASK_TOKEN" - - echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV - echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV - - echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV - echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV - - name: Login to Public ECR run: | if [ "${{ matrix.container_runtime }}" = "finch" ]; then @@ -170,19 +128,3 @@ jobs: with: name: test-results-build-in-source-${{ matrix.container_runtime }} path: TEST_REPORT-*.json - - - name: Reset test account - if: always() - run: | - export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY - export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN - - aws lambda invoke \ - --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ - --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ - ./lambda-output.txt \ - --region us-west-2 \ - --cli-binary-format raw-in-base64-out - - cat ./lambda-output.txt From 3d266020dc448120508bf8b867a92dc9ed0ace44 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 01:42:47 +0000 Subject: [PATCH 10/21] fix: restore BY_CANARY env var to prevent test skip on CI --- .github/workflows/verify-finch-symlink-fix.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/verify-finch-symlink-fix.yml b/.github/workflows/verify-finch-symlink-fix.yml index e549dee2973..2d59d17dba4 100644 --- a/.github/workflows/verify-finch-symlink-fix.yml +++ b/.github/workflows/verify-finch-symlink-fix.yml @@ -13,6 +13,7 @@ env: SAM_CLI_TELEMETRY: 0 SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true UV_PYTHON: python3.11 jobs: From 0d5d7057adc59e59a9c48da17aa8b1e23ead31c7 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 02:16:52 +0000 Subject: [PATCH 11/21] fix: move symlink restore to after container start Containerd/Finch resolves bind mounts at start time, not create time. Moving the symlink restore to after start() ensures the empty directory mountpoints are still present when the container runtime sets up mounts. --- samcli/local/docker/container.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index d73b7dda7b5..76da451f41a 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -287,11 +287,6 @@ def create(self, context): raise DockerContainerCreationFailedException( f"Container creation failed: {ex.explanation}, check template for potential issue" ) - finally: - # Restore any symlinks that were temporarily replaced with directories - # for container mount compatibility. This ensures the host filesystem - # remains unchanged for subsequent invocations. - self._restore_mapped_symlinks() self.id = real_container.id # Output container ID for test parsing @@ -491,6 +486,11 @@ def start(self, input_data=None): if "Ports are not available" in str(ex): raise PortAlreadyInUse(ex.explanation.decode()) from ex raise ex + finally: + # Restore any symlinks that were temporarily replaced with directories + # during container creation for mount compatibility (Finch/containerd). + # Must happen after start() since containerd resolves mounts at start time. + self._restore_mapped_symlinks() def _initialize_concurrency_control(self): """ From 1473a28ecee9e5a8678f5c4b73ed1595a9a27c79 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sat, 14 Feb 2026 02:22:05 +0000 Subject: [PATCH 12/21] fix: restore symlinks at container delete time, not start time Bind mounts must remain valid for the container's entire lifetime. Both Docker and Finch need the empty directory mountpoint to persist until the container is stopped and deleted. Restoring symlinks in the delete() method's finally block ensures proper cleanup alongside the existing host_tmp_dir cleanup. --- samcli/local/docker/container.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/samcli/local/docker/container.py b/samcli/local/docker/container.py index 76da451f41a..7deca4f5a33 100644 --- a/samcli/local/docker/container.py +++ b/samcli/local/docker/container.py @@ -449,6 +449,11 @@ def delete(self): shutil.rmtree(self._host_tmp_dir) LOG.debug("Successfully removed temporary directory %s on the host.", self._host_tmp_dir) + # Restore any symlinks that were temporarily replaced with directories + # during container creation for mount compatibility (Finch/containerd). + # Done at delete time so the bind mounts remain valid for the container's lifetime. + self._restore_mapped_symlinks() + self.id = None def start(self, input_data=None): @@ -486,11 +491,6 @@ def start(self, input_data=None): if "Ports are not available" in str(ex): raise PortAlreadyInUse(ex.explanation.decode()) from ex raise ex - finally: - # Restore any symlinks that were temporarily replaced with directories - # during container creation for mount compatibility (Finch/containerd). - # Must happen after start() since containerd resolves mounts at start time. - self._restore_mapped_symlinks() def _initialize_concurrency_control(self): """ From 3b07a3b3ab4524990b00f3cbf93b10c22889ee69 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 13 Feb 2026 23:38:07 +0000 Subject: [PATCH 13/21] feat: add CloudFormation Language Extensions processing library Implement a local CloudFormation Language Extensions processor supporting: - Fn::ForEach loop expansion in Resources, Conditions, and Outputs - Fn::Length, Fn::ToJsonString intrinsic functions - Fn::FindInMap with DefaultValue support - Conditional DeletionPolicy/UpdateReplacePolicy - Nested ForEach depth validation (max 5 levels) - Partial resolution mode preserving unresolvable references Pipeline architecture: TemplateParsingProcessor -> ForEachProcessor -> IntrinsicResolverProcessor -> DeletionPolicyProcessor -> UpdateReplacePolicyProcessor Includes comprehensive unit tests and CloudFormation compatibility suite. --- pyproject.toml | 1 + .../lib/cfn_language_extensions/__init__.py | 113 + samcli/lib/cfn_language_extensions/api.py | 884 +++++ .../lib/cfn_language_extensions/exceptions.py | 169 + samcli/lib/cfn_language_extensions/models.py | 165 + .../lib/cfn_language_extensions/pipeline.py | 147 + .../processors/__init__.py | 19 + .../processors/deletion_policy.py | 45 + .../processors/foreach.py | 1084 ++++++ .../processors/parsing.py | 129 + .../processors/resource_policy.py | 154 + .../processors/update_replace_policy.py | 45 + .../resolvers/__init__.py | 43 + .../cfn_language_extensions/resolvers/base.py | 601 ++++ .../resolvers/condition_resolver.py | 419 +++ .../resolvers/fn_base64.py | 95 + .../resolvers/fn_find_in_map.py | 256 ++ .../resolvers/fn_if.py | 244 ++ .../resolvers/fn_join.py | 138 + .../resolvers/fn_length.py | 116 + .../resolvers/fn_ref.py | 254 ++ .../resolvers/fn_select.py | 133 + .../resolvers/fn_split.py | 118 + .../resolvers/fn_sub.py | 382 ++ .../resolvers/fn_to_json_string.py | 203 ++ .../cfn_language_extensions/serialization.py | 142 + samcli/lib/cfn_language_extensions/utils.py | 39 + .../lib/cfn_language_extensions/__init__.py | 1 + .../compatibility/__init__.py | 1 + .../templates/conditionWithNull.json | 24 + .../fnFindInMapSelectDefaultValueList.json | 54 + .../fnFindInMapWithDefaultValue.json | 170 + .../fnFindInMapWithDefaultValueListAsIs.json | 52 + ...tValueWithMapTopKeyNotResolveToString.json | 24 + ...fnFindInMapWithFnGetAttInDefaultValue.json | 30 + .../fnFindInMapWithFnRefInDefaultValue.json | 30 + .../templates/fnFindInMapWithIntrinsic.json | 112 + ...indInMapWithMapNameNotResolveToString.json | 23 + ...WithReferenceToIncorrectParameterType.json | 29 + ...dInMapWithUnsupportedFunctionFnGetAtt.json | 35 + ...FindInMapWithUnsupportedFunctionFnRef.json | 30 + ...FindInMapWithUnsupportedFunctionFnSub.json | 42 + ...InMapWithUnsupportedFunctionInMapName.json | 30 + .../templates/fnIfOutputBug.json | 140 + .../fnIfReferencingNonExistingCondition.json | 29 + .../fnIfWithUnresolvableInFalseBranch.json | 142 + .../templates/fnSplitWithEmptySpliter.json | 15 + .../templates/fnSubWithParameter.json | 22 + .../templates/fnSubWithReference.json | 16 + ...ForEachCollectionHasUnresolvableValue.json | 22 + .../fnForEachCollectionNotResolvable.json | 22 + .../conditions/fnForEachCollectionRef.json | 32 + .../fnForEachCollectionRefExpected.json | 38 + .../conditions/fnForEachInOrderElements.json | 43 + .../fnForEachInOrderElementsExpected.json | 52 + .../fnForEachListOfListsFindInMapTest.json | 34 + ...rEachListOfListsFindInMapTestExpected.json | 30 + ...ForEachListOfListsMoreIdentifiersTest.json | 27 + .../fnForEachListOfListsMoreValuesTest.json | 27 + .../conditions/fnForEachListOfListsTest.json | 27 + .../fnForEachListOfListsTestExpected.json | 23 + ...EachListOfListsWithListIdentifierTest.json | 27 + ...hListOfListsWithRefListIdentifierTest.json | 33 + .../fnForEachListOfListsWithRefTest.json | 37 + ...ForEachListOfListsWithRefTestExpected.json | 33 + ...LoopNameConflictingWithLoopIdentifier.json | 51 + .../forEach/conditions/fnForEachMultiple.json | 35 + .../conditions/fnForEachMultipleExpected.json | 44 + .../fnForEachMultipleForEachAfter.json | 35 + ...fnForEachMultipleForEachAfterExpected.json | 44 + .../forEach/conditions/fnForEachNested.json | 62 + .../conditions/fnForEachNestedExpected.json | 50 + ...chNestedWithConflictingIdentifierName.json | 62 + ...nForEachNestedWithConflictingLoopName.json | 62 + ...nForEachPrimitiveTypesInvalidItemTest.json | 26 + .../fnForEachPrimitiveTypesOneItemTest.json | 25 + ...EachPrimitiveTypesOneItemTestExpected.json | 20 + .../fnForEachPrimitiveTypesTest.json | 26 + .../fnForEachPrimitiveTypesTestExpected.json | 26 + .../forEach/conditions/fnForEachSingle.json | 23 + .../conditions/fnForEachSingleExpected.json | 26 + .../fnForEachValueConflictsWithParameter.json | 31 + .../fnForEachValueNotResolvable.json | 25 + .../conditions/fnForEachValueNotUsed.json | 26 + .../fnForEachValueNotUsedExpected.json | 26 + .../fnForEachValueResolvesToNull.json | 25 + .../fnForEachWithCollectionAsEmptyList.json | 23 + ...EachWithCollectionAsEmptyListExpected.json | 12 + ...achWithCollectionAsListOfEmptyStrings.json | 23 + ...ollectionAsListOfEmptyStringsExpected.json | 17 + ...orEachWithCollectionWithNumericValues.json | 23 + ...rEachWithConflictingLoopNameLogicalId.json | 25 + ...fnForEachWithConflictingLoopNameParam.json | 31 + .../fnForEachWithInvalidCollectionAsNull.json | 23 + ...nForEachWithInvalidCollectionAsString.json | 23 + .../fnForEachWithInvalidFragment.json | 23 + ...achWithInvalidIdentifierAsEmptyString.json | 23 + .../fnForEachWithInvalidIdentifierAsList.json | 23 + .../fnForEachWithInvalidIdentifierAsNull.json | 23 + ...ForEachWithInvalidIdentifierAsNumeric.json | 23 + .../fnForEachWithInvalidLayout.json | 14 + .../fnForEachWithNoEchoParameter.json | 31 + ...ithNoIdentifierInOutputKeyAndNotValid.json | 25 + ...chWithNoIdentifierInOutputKeyAndValid.json | 26 + ...IdentifierInOutputKeyAndValidExpected.json | 26 + ...achWithOutputKeyExistingInParentLevel.json | 25 + ...fnForEachWithOutputKeyNonAlphaNumeric.json | 25 + .../fnForEachWithOutputKeyNonResolvable.json | 25 + .../fnForEachWithOutputKeyWithParam.json | 31 + ...ForEachCollectionHasUnresolvableValue.json | 39 + .../fnForEachCollectionNotResolvable.json | 35 + .../outputs/fnForEachCollectionRef.json | 42 + .../fnForEachCollectionRefExpected.json | 40 + .../outputs/fnForEachInOrderElements.json | 46 + .../fnForEachInOrderElementsExpected.json | 44 + .../fnForEachListOfListsFindInMapTest.json | 47 + ...rEachListOfListsFindInMapTestExpected.json | 45 + ...ForEachListOfListsMoreIdentifiersTest.json | 40 + .../fnForEachListOfListsMoreValuesTest.json | 40 + .../outputs/fnForEachListOfListsTest.json | 40 + .../fnForEachListOfListsTestExpected.json | 38 + ...EachListOfListsWithListIdentifierTest.json | 40 + ...hListOfListsWithRefListIdentifierTest.json | 46 + .../fnForEachListOfListsWithRefTest.json | 50 + ...ForEachListOfListsWithRefTestExpected.json | 48 + ...LoopNameConflictingWithLoopIdentifier.json | 50 + .../forEach/outputs/fnForEachMultiple.json | 62 + .../outputs/fnForEachMultipleExpected.json | 60 + .../fnForEachMultipleForEachAfter.json | 62 + ...fnForEachMultipleForEachAfterExpected.json | 60 + .../forEach/outputs/fnForEachNested.json | 68 + .../outputs/fnForEachNestedExpected.json | 40 + ...chNestedWithConflictingIdentifierName.json | 68 + ...nForEachNestedWithConflictingLoopName.json | 68 + ...nForEachPrimitiveTypesInvalidItemTest.json | 39 + .../fnForEachPrimitiveTypesOneItemTest.json | 26 + ...EachPrimitiveTypesOneItemTestExpected.json | 18 + .../outputs/fnForEachPrimitiveTypesTest.json | 39 + .../fnForEachPrimitiveTypesTestExpected.json | 34 + .../forEach/outputs/fnForEachSingle.json | 36 + .../outputs/fnForEachSingleExpected.json | 34 + .../fnForEachValueConflictsWithParameter.json | 41 + .../outputs/fnForEachValueNotResolvable.json | 35 + .../outputs/fnForEachValueNotUsed.json | 36 + .../fnForEachValueNotUsedExpected.json | 30 + .../outputs/fnForEachValueResolvesToNull.json | 35 + .../fnForEachWithCollectionAsEmptyList.json | 24 + ...EachWithCollectionAsEmptyListExpected.json | 12 + ...achWithCollectionAsListOfEmptyStrings.json | 24 + ...ollectionAsListOfEmptyStringsExpected.json | 18 + ...orEachWithCollectionWithNumericValues.json | 24 + ...rEachWithConflictingLoopNameLogicalId.json | 39 + ...fnForEachWithConflictingLoopNameParam.json | 41 + .../fnForEachWithInvalidCollectionAsNull.json | 24 + ...nForEachWithInvalidCollectionAsString.json | 24 + .../outputs/fnForEachWithInvalidFragment.json | 33 + ...achWithInvalidIdentifierAsEmptyString.json | 24 + .../fnForEachWithInvalidIdentifierAsList.json | 24 + .../fnForEachWithInvalidIdentifierAsNull.json | 24 + ...ForEachWithInvalidIdentifierAsNumeric.json | 24 + .../outputs/fnForEachWithInvalidLayout.json | 14 + .../outputs/fnForEachWithNoEchoParameter.json | 29 + ...ithNoIdentifierInOutputKeyAndNotValid.json | 35 + ...chWithNoIdentifierInOutputKeyAndValid.json | 36 + ...IdentifierInOutputKeyAndValidExpected.json | 30 + ...achWithOutputKeyExistingInParentLevel.json | 39 + ...fnForEachWithOutputKeyNonAlphaNumeric.json | 35 + .../fnForEachWithOutputKeyNonResolvable.json | 35 + .../fnForEachWithOutputKeyWithParam.json | 41 + ...ForEachCollectionHasUnresolvableValue.json | 25 + .../fnForEachCollectionNotResolvable.json | 25 + .../resources/fnForEachCollectionRef.json | 32 + .../fnForEachCollectionRefExpected.json | 30 + .../resources/fnForEachInOrderElements.json | 44 + .../fnForEachInOrderElementsExpected.json | 52 + .../fnForEachListOfListsFindInMapTest.json | 33 + ...rEachListOfListsFindInMapTestExpected.json | 37 + ...ForEachListOfListsMoreIdentifiersTest.json | 26 + .../fnForEachListOfListsMoreValuesTest.json | 26 + .../resources/fnForEachListOfListsTest.json | 26 + .../fnForEachListOfListsTestExpected.json | 30 + ...EachListOfListsWithListIdentifierTest.json | 26 + ...hListOfListsWithRefListIdentifierTest.json | 32 + .../fnForEachListOfListsWithRefTest.json | 36 + ...ForEachListOfListsWithRefTestExpected.json | 40 + ...LoopNameConflictingWithLoopIdentifier.json | 44 + .../forEach/resources/fnForEachMultiple.json | 40 + .../resources/fnForEachMultipleExpected.json | 36 + .../fnForEachMultipleForEachAfter.json | 40 + ...fnForEachMultipleForEachAfterExpected.json | 36 + .../forEach/resources/fnForEachNested.json | 82 + .../resources/fnForEachNestedExpected.json | 42 + ...chNestedWithConflictingIdentifierName.json | 82 + ...nForEachNestedWithConflictingLoopName.json | 82 + ...nForEachPrimitiveTypesInvalidItemTest.json | 25 + .../fnForEachPrimitiveTypesOneItemTest.json | 24 + ...EachPrimitiveTypesOneItemTestExpected.json | 14 + .../fnForEachPrimitiveTypesTest.json | 25 + .../fnForEachPrimitiveTypesTestExpected.json | 22 + ...EachPropertiesWithConflictingProperty.json | 45 + ...nForEachPropertiesWithDefinedResource.json | 27 + ...PropertiesWithDefinedResourceExpected.json | 22 + ...nForEachPropertiesWithEmptyCollection.json | 45 + ...PropertiesWithEmptyCollectionExpected.json | 44 + ...orEachPropertiesWithInvalidCollection.json | 45 + ...orEachPropertiesWithInvalidIdentifier.json | 45 + .../fnForEachPropertiesWithNestedForEach.json | 33 + ...chPropertiesWithNestedForEachExpected.json | 22 + ...rEachPropertiesWithinResourcesForEach.json | 45 + ...pertiesWithinResourcesForEachExpected.json | 50 + .../forEach/resources/fnForEachSingle.json | 20 + .../resources/fnForEachSingleExpected.json | 18 + .../fnForEachValueConflictsWithParameter.json | 31 + .../fnForEachValueNotResolvable.json | 25 + .../resources/fnForEachValueNotUsed.json | 26 + .../fnForEachValueNotUsedExpected.json | 18 + .../fnForEachValueResolvesToNull.json | 25 + .../fnForEachWithCollectionAsEmptyList.json | 26 + ...EachWithCollectionAsEmptyListExpected.json | 12 + ...achWithCollectionAsListOfEmptyStrings.json | 20 + ...ollectionAsListOfEmptyStringsExpected.json | 12 + ...orEachWithCollectionWithNumericValues.json | 20 + ...rEachWithConflictingLoopNameLogicalId.json | 26 + ...fnForEachWithConflictingLoopNameParam.json | 26 + .../fnForEachWithInvalidCollectionAsNull.json | 20 + ...nForEachWithInvalidCollectionAsString.json | 20 + .../fnForEachWithInvalidFragment.json | 18 + ...achWithInvalidIdentifierAsEmptyString.json | 20 + .../fnForEachWithInvalidIdentifierAsList.json | 20 + .../fnForEachWithInvalidIdentifierAsNull.json | 20 + ...ForEachWithInvalidIdentifierAsNumeric.json | 20 + .../resources/fnForEachWithInvalidLayout.json | 12 + .../fnForEachWithNoEchoParameter.json | 32 + ...ithNoIdentifierInOutputKeyAndNotValid.json | 20 + ...chWithNoIdentifierInOutputKeyAndValid.json | 20 + ...IdentifierInOutputKeyAndValidExpected.json | 12 + ...achWithOutputKeyExistingInParentLevel.json | 26 + ...fnForEachWithOutputKeyNonAlphaNumeric.json | 20 + .../fnForEachWithOutputKeyNonResolvable.json | 20 + .../fnForEachWithOutputKeyWithParam.json | 26 + .../templates/getAttOfIntrinsicFunction.json | 30 + ...resolvableIntrinsicFunctionInAttrName.json | 20 + ...esolvableIntrinsicFunctionInLogicalId.json | 20 + .../templates/noEchoParameter.json | 22 + .../templates/noResourceAttribute.json | 9 + .../noResourceAttributeWithAwsStackId.json | 12 + .../noResourceAttributeWithAwsStackName.json | 21 + .../templates/outputWithAwsNoValue.json | 10 + .../templates/outputWithNullSub.json | 19 + .../templates/propertiesWithNull.json | 11 + .../templates/refOfIntrinsicFunction.json | 24 + ...sicFunctionWithParameterValueAssigned.json | 22 + .../refOfUnresolvableIntrinsicFunction.json | 20 + ...ceAttributesReferringToMultipleParams.json | 22 + .../resourceAttributesReferringToParam.json | 16 + ...esourceAttributesReferringToPartition.json | 35 + .../resourceAttributesReferringToString.json | 10 + ...rceAttributesWithAccountIdPseudoParam.json | 19 + .../resourceAttributesWithAwsNoValue.json | 19 + .../resourceAttributesWithAwsStackId.json | 45 + .../resourceAttributesWithCondition.json | 21 + .../resourceAttributesWithFnGetAtt.json | 16 + ...urceAttributesWithFnSubWithRightValue.json | 25 + .../templates/resourceAttributesWithMap.json | 32 + ...ttributesWithReferenceToOtherResource.json | 16 + ...sourceAttributesWithRegionPseudoParam.json | 15 + .../resourceAttributesWithWrongValueList.json | 10 + .../templates/resourceWithBase64String.json | 14 + .../templateComparingStringWithBoolean.json | 34 + .../templateComparingStringWithNumber.json | 28 + ...seConditionInResourceWithDefaultValue.json | 45 + ...ionInResourceWithMapNameAsRefFunction.json | 44 + ...ditionInResourceWithToJsonStringParam.json | 47 + ...apWithDifferentConditionsPerResources.json | 70 + .../templateFindInMapWithInvalidRef.json | 47 + ...WithinFnIfDiscardedWhenFalseCondition.json | 51 + ...vableAsFalseConditionWithDefaultValue.json | 51 + ...ditionInResourceWithInvalidStringPath.json | 34 + ...lseConditionInResourceWithNestedInMap.json | 48 + ...TrueConditionInOutputWithInvalidParam.json | 63 + ...ueConditionInResourceWithInvalidParam.json | 42 + ...henFalseConditionInOutputWithRefParam.json | 63 + ...nFalseConditionInResourceWithRefParam.json | 42 + .../templates/templateWithDecimalNumbers.json | 29 + .../templateWithDecimalNumbersResolved.json | 30 + ...emplateWithDifferentTypesOfParameters.json | 33 + .../templateWithFindInMapDefaultValue.json | 65 + .../templateWithFnJoinWithNoValue.json | 23 + ...emplateWithFnLengthAndIncorrectLayout.json | 12 + .../templateWithFnLengthInConditions.json | 23 + ...mplateWithFnLengthReferringToFnBase64.json | 16 + ...ateWithFnLengthReferringToFnFindInMap.json | 43 + .../templateWithFnLengthReferringToFnIf.json | 28 + ...templateWithFnLengthReferringToFnJoin.json | 27 + ...mplateWithFnLengthReferringToFnSelect.json | 18 + ...emplateWithFnLengthReferringToFnSplit.json | 21 + .../templateWithFnLengthReferringToFnSub.json | 30 + ...nLengthReferringToResolvableFunctions.json | 65 + .../templates/templateWithFnLengthSimple.json | 12 + .../templateWithFnLengthWithRef.json | 17 + ...eWithFnLengthWithRefToCDLInConditions.json | 23 + .../templateWithFnMapInCondition.json | 13 + ...WithIntrinsicFunctionsAndDefaultValue.json | 32 + ...emplateWithIntrinsicFunctionsInOutput.json | 67 + ...IntrinsicFunctionsWithoutDefaultValue.json | 31 + ...templateWithIntrinsicInPolicyDocument.json | 59 + ...WithIntrinsicInPolicyDocumentResolved.json | 43 + .../templateWithInvalidConditionRef.json | 25 + .../templateWithInvalidFnSelectIndex.json | 33 + ...plateWithInvalidIntrinsicFunctionType.json | 14 + ...gXIntrinsicAndUnresolvablePseudoParam.json | 13 + .../templateWithMappingMatchNull.json | 28 + .../templateWithNestedCircularConditions.json | 53 + .../templateWithNestedRefConditions.json | 37 + .../templateWithNestedRefNoValue.json | 13 + .../templates/templateWithNoMappingMatch.json | 28 + .../templateWithNonresolvableConditions.json | 26 + .../templates/templateWithNullInput.json | 14 + .../templates/templateWithNullOutput.json | 12 + .../templates/templateWithNullResources.json | 5 + ...templateWithOutputsAndNoValueProperty.json | 14 + .../templates/templateWithRedundantFnSub.json | 20 + ...ourceConditionWithoutConditionSection.json | 13 + ...onWithoutReferencedInConditionSection.json | 21 + .../templateWithResourcesDefinedAsNull.json | 11 + ...teWithSelfReferenceCircularConditions.json | 28 + .../templates/templateWithStringResource.json | 7 + .../templateWithUnresolvedFnSelectRef.json | 33 + .../toJsonFnSplitDirectlyUnderToJson.json | 21 + ...rringToNonResolvableIntrinsicFunction.json | 35 + ...ToNonResolvableIntrinsicFunctionArray.json | 35 + ...ngReferringToNonResolvablePseudoParam.json | 17 + ...erringToNonResolvablePseudoParamArray.json | 17 + ...tringReferringToParamWithDefaultValue.json | 24 + ...ReferringToParamWithDefaultValueArray.json | 30 + ...ringToParamWithNonResolvableFunctions.json | 55 + ...oParamWithNonResolvableFunctionsArray.json | 55 + ...ferringToParamWithResolvableFunctions.json | 84 + ...ngToParamWithResolvableFunctionsArray.json | 85 + ...ngReferringToParamWithoutDefaultValue.json | 29 + .../toJsonStringWithCidrInSelect.json | 17 + .../toJsonStringWithInvalidLayout.json | 15 + .../templates/toJsonStringWithSimpleTags.json | 18 + ...JsonStringWithSplitThatDoesNotResolve.json | 20 + .../toJsonStringWithSplitThatResolves.json | 35 + .../toJsonStringWithStackNamePseudoParam.json | 17 + .../toJsonStringWithinIntrinsicFunctions.json | 69 + .../test_kotlin_compatibility.py | 548 +++ .../lib/cfn_language_extensions/conftest.py | 91 + .../lib/cfn_language_extensions/test_api.py | 1153 ++++++ .../test_api_intrinsic_resolver.py | 438 +++ .../test_condition_resolver.py | 883 +++++ .../test_deletion_policy.py | 719 ++++ .../test_exceptions.py | 204 ++ .../cfn_language_extensions/test_fn_base64.py | 579 +++ .../test_fn_find_in_map.py | 2080 +++++++++++ .../lib/cfn_language_extensions/test_fn_if.py | 942 +++++ .../cfn_language_extensions/test_fn_join.py | 480 +++ .../cfn_language_extensions/test_fn_length.py | 708 ++++ .../cfn_language_extensions/test_fn_ref.py | 1201 +++++++ .../cfn_language_extensions/test_fn_select.py | 613 ++++ .../cfn_language_extensions/test_fn_split.py | 480 +++ .../cfn_language_extensions/test_fn_sub.py | 774 ++++ .../test_fn_to_json_string.py | 913 +++++ .../test_foreach_fallback_resolution.py | 263 ++ .../test_foreach_processor.py | 3126 +++++++++++++++++ .../cfn_language_extensions/test_models.py | 285 ++ .../cfn_language_extensions/test_package.py | 33 + .../cfn_language_extensions/test_parsing.py | 315 ++ .../test_parsing_properties.py | 360 ++ .../cfn_language_extensions/test_pipeline.py | 455 +++ .../test_resolvers_base.py | 800 +++++ .../test_serialization.py | 770 ++++ .../test_update_replace_policy.py | 780 ++++ 374 files changed, 36574 insertions(+) create mode 100644 samcli/lib/cfn_language_extensions/__init__.py create mode 100644 samcli/lib/cfn_language_extensions/api.py create mode 100644 samcli/lib/cfn_language_extensions/exceptions.py create mode 100644 samcli/lib/cfn_language_extensions/models.py create mode 100644 samcli/lib/cfn_language_extensions/pipeline.py create mode 100644 samcli/lib/cfn_language_extensions/processors/__init__.py create mode 100644 samcli/lib/cfn_language_extensions/processors/deletion_policy.py create mode 100644 samcli/lib/cfn_language_extensions/processors/foreach.py create mode 100644 samcli/lib/cfn_language_extensions/processors/parsing.py create mode 100644 samcli/lib/cfn_language_extensions/processors/resource_policy.py create mode 100644 samcli/lib/cfn_language_extensions/processors/update_replace_policy.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/__init__.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/base.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/condition_resolver.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_base64.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_if.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_join.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_length.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_ref.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_select.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_split.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_sub.py create mode 100644 samcli/lib/cfn_language_extensions/resolvers/fn_to_json_string.py create mode 100644 samcli/lib/cfn_language_extensions/serialization.py create mode 100644 samcli/lib/cfn_language_extensions/utils.py create mode 100644 tests/unit/lib/cfn_language_extensions/__init__.py create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/__init__.py create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/conditionWithNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapSelectDefaultValueList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueListAsIs.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnGetAttInDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnRefInDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithIntrinsic.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithMapNameNotResolveToString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithReferenceToIncorrectParameterType.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnGetAtt.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnSub.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionInMapName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfOutputBug.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfReferencingNonExistingCondition.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfWithUnresolvableInFalseBranch.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSplitWithEmptySpliter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithReference.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionHasUnresolvableValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRefExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElements.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElementsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreIdentifiersTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreValuesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachLoopNameConflictingWithLoopIdentifier.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultiple.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfterExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNested.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingIdentifierName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingLoopName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesInvalidItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingle.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueConflictsWithParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsed.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueResolvesToNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyListExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStrings.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStringsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionWithNumericValues.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameLogicalId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidFragment.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsEmptyString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidLayout.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoEchoParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyExistingInParentLevel.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonAlphaNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyWithParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionHasUnresolvableValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRefExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElements.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElementsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreIdentifiersTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreValuesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachLoopNameConflictingWithLoopIdentifier.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultiple.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfterExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNested.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingIdentifierName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingLoopName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesInvalidItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingle.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueConflictsWithParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsed.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueResolvesToNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyListExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStrings.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStringsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionWithNumericValues.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameLogicalId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidFragment.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsEmptyString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidLayout.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoEchoParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyExistingInParentLevel.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonAlphaNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyWithParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionHasUnresolvableValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRefExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElements.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElementsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreIdentifiersTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreValuesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefListIdentifierTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachLoopNameConflictingWithLoopIdentifier.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultiple.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfterExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNested.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingIdentifierName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingLoopName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesInvalidItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTest.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTestExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithConflictingProperty.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResource.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResourceExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollection.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollectionExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidCollection.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidIdentifier.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEach.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEachExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEach.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEachExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingle.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingleExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueConflictsWithParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsed.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsedExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueResolvesToNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyListExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStrings.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStringsExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionWithNumericValues.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameLogicalId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidFragment.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsEmptyString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidLayout.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoEchoParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValid.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyExistingInParentLevel.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonAlphaNumeric.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonResolvable.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyWithParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfIntrinsicFunction.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInAttrName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInLogicalId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/noEchoParameter.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttribute.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackName.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithAwsNoValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithNullSub.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/propertiesWithNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunction.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunctionWithParameterValueAssigned.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfUnresolvableIntrinsicFunction.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToMultipleParams.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToPartition.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToString.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAccountIdPseudoParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsNoValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsStackId.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithCondition.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnGetAtt.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnSubWithRightValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithMap.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithReferenceToOtherResource.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithRegionPseudoParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithWrongValueList.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceWithBase64String.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithBoolean.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithNumber.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithMapNameAsRefFunction.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithToJsonStringParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithDifferentConditionsPerResources.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithInvalidRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfDiscardedWhenFalseCondition.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfResolvableAsFalseConditionWithDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithInvalidStringPath.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithNestedInMap.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInOutputWithInvalidParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInResourceWithInvalidParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInOutputWithRefParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInResourceWithRefParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbers.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbersResolved.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDifferentTypesOfParameters.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFindInMapDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnJoinWithNoValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthAndIncorrectLayout.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthInConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnBase64.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnFindInMap.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnIf.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnJoin.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSelect.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSplit.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSub.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToResolvableFunctions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthSimple.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRefToCDLInConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnMapInCondition.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsAndDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsInOutput.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsWithoutDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocument.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocumentResolved.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidConditionRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidFnSelectIndex.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidIntrinsicFunctionType.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithLangXIntrinsicAndUnresolvablePseudoParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithMappingMatchNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedCircularConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefNoValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNoMappingMatch.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNonresolvableConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullInput.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullOutput.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullResources.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithOutputsAndNoValueProperty.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithRedundantFnSub.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutConditionSection.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutReferencedInConditionSection.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourcesDefinedAsNull.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithSelfReferenceCircularConditions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithStringResource.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithUnresolvedFnSelectRef.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonFnSplitDirectlyUnderToJson.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunction.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunctionArray.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParamArray.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValueArray.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctionsArray.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctionsArray.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithoutDefaultValue.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithCidrInSelect.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithInvalidLayout.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSimpleTags.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatDoesNotResolve.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatResolves.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithStackNamePseudoParam.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithinIntrinsicFunctions.json create mode 100644 tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py create mode 100644 tests/unit/lib/cfn_language_extensions/conftest.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_api.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_api_intrinsic_resolver.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_condition_resolver.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_deletion_policy.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_exceptions.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_base64.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_if.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_join.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_length.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_ref.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_select.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_split.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_sub.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_fn_to_json_string.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_foreach_fallback_resolution.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_foreach_processor.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_models.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_package.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_parsing.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_parsing_properties.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_pipeline.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_resolvers_base.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_serialization.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_update_replace_policy.py diff --git a/pyproject.toml b/pyproject.toml index 4359b833cc4..3571153cf8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ max-statements = 80 "__init__.py" = ["F401", "E501"] "integration_uri.py" = ["E501"] # ARNs are long. "app.py" = ["E501"] # Doc links are long. +"samcli/lib/cfn_language_extensions/**/*.py" = ["PLR2004", "PLR0911", "PLR1714"] # Magic values and return statements are acceptable in intrinsic resolvers [tool.black] line-length = 120 diff --git a/samcli/lib/cfn_language_extensions/__init__.py b/samcli/lib/cfn_language_extensions/__init__.py new file mode 100644 index 00000000000..2154b3d89fa --- /dev/null +++ b/samcli/lib/cfn_language_extensions/__init__.py @@ -0,0 +1,113 @@ +""" +CloudFormation Language Extensions Python Package. + +This package provides a standalone library for processing CloudFormation templates +with extended intrinsic functions (Fn::ForEach, Fn::Length, Fn::ToJsonString, +Fn::FindInMap with DefaultValue). + +The package enables local template validation, CI/CD pipeline integration, +IDE tooling, and testing without deployment. It is designed to integrate +with the AWS SAM ecosystem. + +Internal Package: CFNLanguageExtensions +Commit: ab10aed4c2a72e0307e7f209141a22cdd2e27562 +""" + +__version__ = "0.1.0" + +from samcli.lib.cfn_language_extensions.api import ( + create_default_intrinsic_resolver, + create_default_pipeline, + load_template, + load_template_from_json, + load_template_from_yaml, + process_template, +) +from samcli.lib.cfn_language_extensions.exceptions import ( + InvalidTemplateException, + PublicFacingErrorMessages, + UnresolvableReferenceError, +) +from samcli.lib.cfn_language_extensions.models import ( + ParsedTemplate, + PseudoParameterValues, + ResolutionMode, + TemplateProcessingContext, +) +from samcli.lib.cfn_language_extensions.pipeline import ( + ProcessingPipeline, + TemplateProcessor, +) +from samcli.lib.cfn_language_extensions.processors import ( + TemplateParsingProcessor, +) +from samcli.lib.cfn_language_extensions.resolvers import ( + RESOLVABLE_INTRINSICS, + UNRESOLVABLE_INTRINSICS, + IntrinsicFunctionResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.sam_integration import ( + AWS_LANGUAGE_EXTENSIONS_TRANSFORM, + LanguageExtensionResult, + SAMLanguageExtensionsPlugin, + check_using_language_extension, + contains_loop_variable, + detect_dynamic_artifact_properties, + detect_foreach_dynamic_properties, + expand_language_extensions, + process_nested_stacks, + process_template_for_sam_cli, + resolve_collection, + resolve_parameter_collection, + substitute_loop_variable, +) +from samcli.lib.cfn_language_extensions.serialization import ( + serialize_to_json, + serialize_to_yaml, +) + +# Public API will be exported here as modules are implemented +__all__ = [ + "__version__", + # Exceptions + "InvalidTemplateException", + "UnresolvableReferenceError", + "PublicFacingErrorMessages", + # Models + "ResolutionMode", + "PseudoParameterValues", + "ParsedTemplate", + "TemplateProcessingContext", + # Pipeline + "TemplateProcessor", + "ProcessingPipeline", + # Processors + "TemplateParsingProcessor", + # Resolvers + "IntrinsicFunctionResolver", + "IntrinsicResolver", + "RESOLVABLE_INTRINSICS", + "UNRESOLVABLE_INTRINSICS", + # Serialization + "serialize_to_json", + "serialize_to_yaml", + # API + "process_template", + "create_default_pipeline", + "create_default_intrinsic_resolver", + "load_template_from_json", + "load_template_from_yaml", + "load_template", + # SAM Integration + "SAMLanguageExtensionsPlugin", + "LanguageExtensionResult", + "check_using_language_extension", + "expand_language_extensions", + "process_template_for_sam_cli", + "process_nested_stacks", + "AWS_LANGUAGE_EXTENSIONS_TRANSFORM", + "substitute_loop_variable", +] diff --git a/samcli/lib/cfn_language_extensions/api.py b/samcli/lib/cfn_language_extensions/api.py new file mode 100644 index 00000000000..524658c3477 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/api.py @@ -0,0 +1,884 @@ +""" +Public API for CloudFormation Language Extensions processing. + +This module provides the main entry point for processing CloudFormation templates +with language extensions. It creates a default processing pipeline with all +necessary processors and resolvers. + +The main function `process_template` accepts a template dictionary and optional +processing options, and returns the processed template with language extensions +resolved. + +Requirements: + - 12.1: Provide a process_template function that accepts a template dictionary + - 12.4: Support loading templates from JSON and YAML files + - 12.5: Return the processed template as a Python dictionary +""" + +import json +import os +from typing import Any, Dict, List, Optional + +import yaml + +from samcli.lib.cfn_language_extensions.models import ( + PseudoParameterValues, + ResolutionMode, + TemplateProcessingContext, +) +from samcli.lib.cfn_language_extensions.pipeline import ProcessingPipeline, TemplateProcessor +from samcli.lib.cfn_language_extensions.processors import ( + DeletionPolicyProcessor, + ForEachProcessor, + TemplateParsingProcessor, + UpdateReplacePolicyProcessor, +) +from samcli.lib.cfn_language_extensions.resolvers import ( + ConditionResolver, + FnBase64Resolver, + FnFindInMapResolver, + FnIfResolver, + FnJoinResolver, + FnLengthResolver, + FnRefResolver, + FnSelectResolver, + FnSplitResolver, + FnSubResolver, + FnToJsonStringResolver, + IntrinsicResolver, +) + + +def create_default_intrinsic_resolver(context: TemplateProcessingContext) -> IntrinsicResolver: + """ + Create an IntrinsicResolver with all standard resolvers registered. + + This function creates an IntrinsicResolver instance and registers all + the standard intrinsic function resolvers in the correct order for + proper resolution. + + Args: + context: The template processing context. + + Returns: + An IntrinsicResolver with all standard resolvers registered. + """ + resolver = IntrinsicResolver(context) + + # Register resolvers in order of dependency + # Condition-related resolvers first (needed by Fn::If) + resolver.register_resolver(ConditionResolver) + resolver.register_resolver(FnIfResolver) + + # Basic resolvers + resolver.register_resolver(FnRefResolver) + resolver.register_resolver(FnBase64Resolver) + + # String manipulation resolvers + resolver.register_resolver(FnJoinResolver) + resolver.register_resolver(FnSplitResolver) + resolver.register_resolver(FnSubResolver) + + # List/collection resolvers + resolver.register_resolver(FnSelectResolver) + resolver.register_resolver(FnLengthResolver) + + # Map lookup resolver + resolver.register_resolver(FnFindInMapResolver) + + # JSON conversion resolver + resolver.register_resolver(FnToJsonStringResolver) + + return resolver + + +class IntrinsicResolverProcessor: + """ + Processor that resolves intrinsic functions in the template. + + This processor wraps an IntrinsicResolver and applies it to the + template fragment, resolving all intrinsic functions that can be + resolved locally. + + Note: The Conditions section is handled specially - condition intrinsics + (Fn::Equals, Fn::And, Fn::Or, Fn::Not) are NOT resolved to boolean values. + They are only resolved when evaluating Fn::If. This matches the Kotlin + implementation behavior. + """ + + def __init__(self, intrinsic_resolver: IntrinsicResolver) -> None: + """ + Initialize the processor with an intrinsic resolver. + + Args: + intrinsic_resolver: The IntrinsicResolver to use for resolution. + """ + self._resolver = intrinsic_resolver + + def process_template(self, context: TemplateProcessingContext) -> None: + """ + Process the template by resolving intrinsic functions. + + This method walks through the template fragment and resolves + all intrinsic functions that can be resolved locally. + + The Conditions section is handled specially - condition intrinsics + are preserved and not resolved to boolean values. + + Resources and Outputs with false conditions use partial resolution + where unresolvable intrinsics are replaced with AWS::NoValue instead + of throwing errors. + + After resolution, properties with AWS::NoValue are removed. + + Args: + context: The template processing context. + """ + # Process each section separately to handle Conditions specially + fragment = context.fragment + + # Pre-evaluate all conditions to detect circular dependencies + # Use fragment's conditions (which may have been expanded by ForEach) + # instead of parsed_template's conditions + if "Conditions" in fragment and fragment["Conditions"]: + self._pre_evaluate_conditions(context, fragment["Conditions"]) + + # Validate resource conditions reference valid conditions + self._validate_resource_conditions(context, fragment) + + # Process Conditions section without resolving condition intrinsics + if "Conditions" in fragment: + fragment["Conditions"] = self._resolve_conditions_section(fragment["Conditions"]) + + # Process Resources section with special handling for false conditions + if "Resources" in fragment: + fragment["Resources"] = self._resolve_resources_section(fragment["Resources"], context) + + # Process Outputs section with special handling for false conditions + if "Outputs" in fragment: + fragment["Outputs"] = self._resolve_outputs_section(fragment["Outputs"], context) + + # Process other sections normally + for key in list(fragment.keys()): + if key not in ("Conditions", "Resources", "Outputs"): + fragment[key] = self._resolver.resolve_value(fragment[key]) + + # Remove AWS::NoValue references from Resources (but preserve policy attrs) + if "Resources" in fragment: + fragment["Resources"] = self._remove_no_value(fragment["Resources"]) + + context.fragment = fragment + + def _resolve_resources_section(self, resources: Any, context: TemplateProcessingContext) -> Any: + """ + Resolve intrinsic functions in the Resources section. + + Resources with false conditions use partial resolution where + unresolvable intrinsics are replaced with AWS::NoValue. + + Args: + resources: The Resources section dictionary. + context: The template processing context. + + Returns: + The Resources section with intrinsics resolved. + """ + if not isinstance(resources, dict): + return self._resolver.resolve_value(resources) + + result = {} + for logical_id, resource in resources.items(): + if isinstance(resource, dict) and "Condition" in resource: + condition_name = resource["Condition"] + # Check if condition is false + if condition_name in context.resolved_conditions: + if not context.resolved_conditions[condition_name]: + # False condition - use partial resolution + result[logical_id] = self._resolve_with_false_condition(resource) + continue + # Normal resolution + result[logical_id] = self._resolver.resolve_value(resource) + return result + + def _resolve_outputs_section(self, outputs: Any, context: TemplateProcessingContext) -> Any: + """ + Resolve intrinsic functions in the Outputs section. + + Outputs with false conditions use partial resolution where + unresolvable intrinsics are replaced with AWS::NoValue. + + Args: + outputs: The Outputs section dictionary. + context: The template processing context. + + Returns: + The Outputs section with intrinsics resolved. + """ + if not isinstance(outputs, dict): + return self._resolver.resolve_value(outputs) + + result = {} + for logical_id, output in outputs.items(): + if isinstance(output, dict) and "Condition" in output: + condition_name = output["Condition"] + # Check if condition is false + if condition_name in context.resolved_conditions: + if not context.resolved_conditions[condition_name]: + # False condition - use partial resolution + result[logical_id] = self._resolve_with_false_condition(output) + continue + # Normal resolution + result[logical_id] = self._resolver.resolve_value(output) + return result + + def _resolve_with_false_condition(self, value: Any) -> Any: + """ + Resolve a value using partial resolution for false conditions. + + In this mode, unresolvable intrinsics are replaced with AWS::NoValue + instead of throwing errors. + + Args: + value: The value to resolve. + + Returns: + The resolved value with unresolvable intrinsics replaced by AWS::NoValue. + """ + return self._partial_resolve(value) + + def _partial_resolve(self, value: Any) -> Any: + """ + Partially resolve a value, replacing unresolvable intrinsics with AWS::NoValue. + + This is used for resources/outputs with false conditions where we want to + preserve the structure but replace unresolvable parts with AWS::NoValue. + + For false-condition resources, Refs to parameters are also replaced with + AWS::NoValue (even if they have default values) to match Kotlin behavior. + + Args: + value: The value to partially resolve. + + Returns: + The partially resolved value. + """ + if isinstance(value, dict): + if len(value) == 1: + key = next(iter(value.keys())) + inner_value = value[key] + + # Check if this is an intrinsic function + if key == "Ref": + # For false-condition resources, replace Refs to parameters with AWS::NoValue + # Only keep Refs to pseudo-parameters that are always available + always_available_refs = { + "AWS::NoValue", + "AWS::Region", + "AWS::AccountId", + "AWS::StackName", + "AWS::StackId", + "AWS::Partition", + "AWS::URLSuffix", + } + if isinstance(inner_value, str) and inner_value not in always_available_refs: + return {"Ref": "AWS::NoValue"} + # Try to resolve pseudo-parameters + try: + return self._resolver.resolve_value(value) + except Exception: + return {"Ref": "AWS::NoValue"} + + elif key.startswith("Fn::") or key == "Condition": + # Try to resolve it + try: + return self._resolver.resolve_value(value) + except Exception: + # Can't resolve - replace unresolvable parts with AWS::NoValue + if key == "Fn::FindInMap": + # FindInMap - try to partially resolve arguments + return self._partial_resolve_find_in_map(value) + elif key == "Fn::ToJsonString": + # ToJsonString - replace with AWS::NoValue if can't resolve + return {"Ref": "AWS::NoValue"} + else: + # Other intrinsics - try to partially resolve arguments + resolved_inner = self._partial_resolve(inner_value) + return {key: resolved_inner} + + # Regular dict - recursively resolve values + return {k: self._partial_resolve(v) for k, v in value.items()} + + elif isinstance(value, list): + return [self._partial_resolve(item) for item in value] + + # Primitive value - return as-is + return value + + def _partial_resolve_find_in_map(self, value: Dict[str, Any]) -> Any: + """ + Partially resolve Fn::FindInMap, replacing unresolvable parts with AWS::NoValue. + + For false-condition resources, Refs to parameters in FindInMap arguments + are replaced with AWS::NoValue. However, string literals that don't exist + in the mappings still throw errors. + + Args: + value: The Fn::FindInMap intrinsic function. + + Returns: + The partially resolved FindInMap or the resolved value. + """ + from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + args = value.get("Fn::FindInMap", []) + if not isinstance(args, list) or len(args) < 3: + return value + + # Partially resolve each argument (this will replace Refs to params with AWS::NoValue) + resolved_args = [] + for i, arg in enumerate(args): + resolved = self._partial_resolve(arg) + resolved_args.append(resolved) + + # Check if any of the first 3 args are AWS::NoValue + has_no_value = any( + isinstance(a, dict) and a.get("Ref") == "AWS::NoValue" + for a in resolved_args[:3] # Only check map name, top key, second key + ) + + if has_no_value: + # Can't resolve - return with partially resolved args + return {"Fn::FindInMap": resolved_args} + + # All args are resolved to strings - validate they exist in mappings + # If they're string literals that don't exist, throw an error + map_name = resolved_args[0] + top_key = resolved_args[1] + second_key = resolved_args[2] + + if isinstance(map_name, str) and isinstance(top_key, str) and isinstance(second_key, str): + # All keys are strings - try to do the lookup + # This will throw an error if the keys don't exist + try: + result = self._resolver.resolve_value({"Fn::FindInMap": resolved_args}) + return result + except InvalidTemplateException: + # Re-raise - string literals that don't exist should error + raise + except Exception: + # Other errors - return with partially resolved args + return {"Fn::FindInMap": resolved_args} + + # Some args are not strings - return with partially resolved args + return {"Fn::FindInMap": resolved_args} + + def _validate_resource_conditions(self, context: TemplateProcessingContext, fragment: Dict[str, Any]) -> None: + """ + Validate that resource Condition attributes reference valid conditions. + + Args: + context: The template processing context. + fragment: The template fragment. + + Raises: + InvalidTemplateException: If a resource references a non-existent condition. + """ + from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + resources = fragment.get("Resources", {}) + conditions = fragment.get("Conditions", {}) + + if not isinstance(resources, dict): + return + + for logical_id, resource in resources.items(): + if isinstance(resource, dict) and "Condition" in resource: + condition_name = resource["Condition"] + if isinstance(condition_name, str): + # Check if condition exists + if not conditions or condition_name not in conditions: + raise InvalidTemplateException( + f"Resource '{logical_id}' references non-existent condition '{condition_name}'" + ) + + def _pre_evaluate_conditions(self, context: TemplateProcessingContext, conditions: Dict[str, Any]) -> None: + """ + Pre-evaluate all conditions to detect circular dependencies. + + This method first checks for circular dependencies using graph traversal, + then evaluates all conditions to populate the resolved_conditions cache. + + Args: + context: The template processing context. + conditions: The conditions dictionary from the fragment. + + Raises: + InvalidTemplateException: If circular condition dependencies are detected. + """ + from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + # Update parsed_template conditions to match fragment (for ForEach expansion) + if context.parsed_template: + context.parsed_template.conditions = conditions + + # Build dependency graph + dependencies = {} + for condition_name, condition_def in conditions.items(): + dependencies[condition_name] = self._extract_condition_dependencies(condition_def) + + # Detect cycles using DFS + visited = set() + rec_stack = set() + + def detect_cycle(node, path): + """DFS to detect cycles, returns the cycle path if found.""" + if node in rec_stack: + # Found a cycle - return the path from the cycle start + cycle_start = path.index(node) + return path[cycle_start:] + if node in visited: + return None + + visited.add(node) + rec_stack.add(node) + path.append(node) + + for dep in dependencies.get(node, []): + if dep in conditions: # Only check conditions that exist + cycle = detect_cycle(dep, path) + if cycle: + return cycle + + path.pop() + rec_stack.remove(node) + return None + + # Check each condition for cycles + for condition_name in conditions: + visited.clear() + rec_stack.clear() + cycle = detect_cycle(condition_name, []) + if cycle: + # Format error message like Kotlin: "Found circular condition dependency between X and Y" + if len(cycle) >= 2: + raise InvalidTemplateException( + f"Found circular condition dependency between {cycle[0]} and {cycle[1]}" + ) + else: + raise InvalidTemplateException( + f"Found circular condition dependency between {cycle[0]} and {cycle[0]}" + ) + + # Now evaluate all conditions (no cycles, so this is safe) + for condition_name in conditions: + if condition_name not in context.resolved_conditions: + try: + self._resolver.resolve_value({"Condition": condition_name}) + except InvalidTemplateException: + raise + + def _extract_condition_dependencies(self, value: Any) -> set: + """ + Extract all condition references from a condition definition. + + Args: + value: The condition definition to analyze. + + Returns: + A set of condition names that this definition depends on. + """ + dependencies = set() + + if isinstance(value, dict): + if len(value) == 1: + key = next(iter(value.keys())) + if key == "Condition": + # Direct condition reference + ref = value[key] + if isinstance(ref, str): + dependencies.add(ref) + else: + # Recurse into the value + dependencies.update(self._extract_condition_dependencies(value[key])) + else: + for v in value.values(): + dependencies.update(self._extract_condition_dependencies(v)) + elif isinstance(value, list): + for item in value: + dependencies.update(self._extract_condition_dependencies(item)) + + return dependencies + + def _remove_no_value(self, value: Any, preserve_policy_attrs: bool = True) -> Any: + """ + Remove properties that have AWS::NoValue as their value. + + AWS::NoValue is a pseudo-parameter that indicates a property should + be removed from the template. When Fn::If returns AWS::NoValue, + the resolver returns None. + + Note: DeletionPolicy and UpdateReplacePolicy attributes are preserved + even if they are AWS::NoValue, so that the policy processors can + validate them and raise appropriate errors. + + Note: AWS::NoValue is NOT removed from intrinsic function arguments + (e.g., Fn::FindInMap, Fn::Join) as it may be intentionally placed there + for false-condition resources. + + Args: + value: The value to process. + preserve_policy_attrs: If True, preserve DeletionPolicy and + UpdateReplacePolicy even if AWS::NoValue. + + Returns: + The value with AWS::NoValue properties removed. + """ + # Policy attributes that should not be removed even if AWS::NoValue + POLICY_ATTRS = {"DeletionPolicy", "UpdateReplacePolicy"} + # Intrinsic functions whose arguments should preserve AWS::NoValue + INTRINSIC_FUNCTIONS = { + "Fn::FindInMap", + "Fn::Join", + "Fn::Select", + "Fn::Split", + "Fn::Sub", + "Fn::If", + "Fn::And", + "Fn::Or", + "Fn::Not", + "Fn::Equals", + "Fn::GetAtt", + "Fn::GetAZs", + "Fn::ImportValue", + "Fn::Length", + "Fn::ToJsonString", + "Fn::Base64", + "Fn::Cidr", + "Fn::ForEach", + "Ref", + "Condition", + } + + if isinstance(value, dict): + # Check if this is an intrinsic function - if so, preserve AWS::NoValue in args + if len(value) == 1: + key = next(iter(value.keys())) + if key in INTRINSIC_FUNCTIONS: + # This is an intrinsic function - preserve AWS::NoValue in arguments + inner = value[key] + if isinstance(inner, list): + # Recursively process but don't filter out AWS::NoValue + return {key: [self._remove_no_value(item, preserve_policy_attrs) for item in inner]} + elif isinstance(inner, dict): + return {key: self._remove_no_value(inner, preserve_policy_attrs)} + else: + return value + + result = {} + for k, v in value.items(): + # Preserve policy attributes for validation by policy processors + if preserve_policy_attrs and k in POLICY_ATTRS: + result[k] = self._remove_no_value(v, preserve_policy_attrs=False) + continue + # Check if value is {"Ref": "AWS::NoValue"} or None + if self._is_no_value(v) or v is None: + continue # Skip this property + result[k] = self._remove_no_value(v, preserve_policy_attrs) + return result + elif isinstance(value, list): + # Filter out AWS::NoValue and None from lists (but not intrinsic args - handled above) + return [ + self._remove_no_value(item, preserve_policy_attrs) + for item in value + if not self._is_no_value(item) and item is not None + ] + return value + + def _is_no_value(self, value: Any) -> bool: + """ + Check if a value is {"Ref": "AWS::NoValue"}. + + Args: + value: The value to check. + + Returns: + True if the value is AWS::NoValue, False otherwise. + """ + if isinstance(value, dict) and len(value) == 1: + if "Ref" in value and value["Ref"] == "AWS::NoValue": + return True + return False + + def _resolve_conditions_section(self, conditions: Any) -> Any: + """ + Resolve intrinsic functions in the Conditions section. + + This method resolves intrinsics like Ref, Fn::Sub, etc. but + preserves condition intrinsics (Fn::Equals, Fn::And, Fn::Or, Fn::Not) + without evaluating them to boolean values. + + Args: + conditions: The Conditions section dictionary. + + Returns: + The Conditions section with non-condition intrinsics resolved. + """ + if not isinstance(conditions, dict): + return conditions + + result = {} + for condition_name, condition_def in conditions.items(): + result[condition_name] = self._resolve_condition_value(condition_def) + return result + + def _resolve_condition_value(self, value: Any) -> Any: + """ + Resolve a value within the Conditions section. + + Condition intrinsics (Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition) + are preserved but their arguments are resolved. + + Args: + value: The value to resolve. + + Returns: + The resolved value with condition intrinsics preserved. + """ + if isinstance(value, dict): + if len(value) == 1: + key = next(iter(value.keys())) + args = value[key] + + # Condition intrinsics - preserve but resolve arguments + if key in ("Fn::Equals", "Fn::And", "Fn::Or", "Fn::Not", "Condition"): + if key == "Condition": + # Condition references are kept as-is + return value + else: + # Resolve arguments but keep the intrinsic structure + resolved_args = self._resolve_condition_value(args) + return {key: resolved_args} + else: + # Other intrinsics (Ref, Fn::Sub, etc.) - resolve normally + return self._resolver.resolve_value(value) + else: + # Multiple keys - resolve each value + return {k: self._resolve_condition_value(v) for k, v in value.items()} + elif isinstance(value, list): + return [self._resolve_condition_value(item) for item in value] + else: + # Primitives - return as-is + return value + + +def create_default_pipeline(context: TemplateProcessingContext) -> ProcessingPipeline: + """ + Create a default processing pipeline with all standard processors. + + The default pipeline includes: + 1. TemplateParsingProcessor - Parses and validates template structure + 2. ForEachProcessor - Expands Fn::ForEach loops + 3. IntrinsicResolverProcessor - Resolves intrinsic functions + 4. DeletionPolicyProcessor - Validates and resolves DeletionPolicy + 5. UpdateReplacePolicyProcessor - Validates and resolves UpdateReplacePolicy + + Args: + context: The template processing context. + + Returns: + A ProcessingPipeline with all standard processors configured. + """ + # Create the intrinsic resolver + intrinsic_resolver = create_default_intrinsic_resolver(context) + + # Create processors in order + processors: List[TemplateProcessor] = [ + TemplateParsingProcessor(), + ForEachProcessor(intrinsic_resolver=intrinsic_resolver), + IntrinsicResolverProcessor(intrinsic_resolver), + DeletionPolicyProcessor(), + UpdateReplacePolicyProcessor(), + ] + + return ProcessingPipeline(processors) + + +def process_template( + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + pseudo_parameters: Optional[PseudoParameterValues] = None, + resolution_mode: ResolutionMode = ResolutionMode.PARTIAL, +) -> Dict[str, Any]: + """ + Process a CloudFormation template with language extensions. + + This is the main entry point for processing CloudFormation templates. + It creates a default processing pipeline and runs the template through + all processors to resolve language extensions. + + The function: + 1. Creates a TemplateProcessingContext with the provided options + 2. Creates a default pipeline with all standard processors + 3. Processes the template through the pipeline + 4. Returns the processed template as a dictionary + + Args: + template: The CloudFormation template as a dictionary. + parameter_values: Optional dictionary of parameter values to use + for resolving Ref to parameters. + pseudo_parameters: Optional PseudoParameterValues for resolving + AWS pseudo-parameters (AWS::Region, etc.). + resolution_mode: How to handle unresolvable references. + PARTIAL (default): Preserve unresolvable refs. + FULL: Raise error on unresolvable refs. + + Returns: + The processed template as a dictionary with language extensions + resolved. + + Raises: + InvalidTemplateException: If the template is invalid or processing + fails. + + Example: + >>> template = { + ... "Resources": { + ... "Fn::ForEach::Topics": [ + ... "TopicName", + ... ["Alerts", "Notifications"], + ... {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}} + ... ] + ... } + ... } + >>> result = process_template(template) + >>> print(result["Resources"]) + {'TopicAlerts': {'Type': 'AWS::SNS::Topic'}, + 'TopicNotifications': {'Type': 'AWS::SNS::Topic'}} + + Requirements: + - 12.1: Accept a template dictionary and processing options + - 12.4: Support both JSON and YAML template input formats + - 12.5: Return the processed template as a Python dictionary + """ + # Create the processing context + context = TemplateProcessingContext( + fragment=template.copy(), # Make a copy to avoid modifying the input + parameter_values=parameter_values or {}, + pseudo_parameters=pseudo_parameters, + resolution_mode=resolution_mode, + ) + + # Create and run the default pipeline + pipeline = create_default_pipeline(context) + return pipeline.process_template(context) + + +def load_template_from_json(file_path: str) -> Dict[str, Any]: + """ + Load a CloudFormation template from a JSON file. + + This function reads a JSON file and parses it into a dictionary + suitable for processing with the process_template function. + + Args: + file_path: Path to the JSON template file. + + Returns: + The template as a Python dictionary. + + Raises: + FileNotFoundError: If the file does not exist. + json.JSONDecodeError: If the file contains invalid JSON. + IsADirectoryError: If the path points to a directory. + + Example: + >>> template = load_template_from_json("template.json") + >>> result = process_template(template) + + Requirements: + - 12.4: Support loading templates from JSON files + """ + with open(file_path, "r", encoding="utf-8") as f: + result = json.load(f) + return dict(result) if isinstance(result, dict) else {} + + +def load_template_from_yaml(file_path: str) -> Dict[str, Any]: + """ + Load a CloudFormation template from a YAML file. + + This function reads a YAML file and parses it into a dictionary + suitable for processing with the process_template function. + + CloudFormation templates often use YAML format for better readability, + especially when using multi-line strings or complex nested structures. + + Args: + file_path: Path to the YAML template file. + + Returns: + The template as a Python dictionary. + + Raises: + FileNotFoundError: If the file does not exist. + yaml.YAMLError: If the file contains invalid YAML. + IsADirectoryError: If the path points to a directory. + + Example: + >>> template = load_template_from_yaml("template.yaml") + >>> result = process_template(template) + + Requirements: + - 12.4: Support loading templates from YAML files + """ + with open(file_path, "r", encoding="utf-8") as f: + result = yaml.safe_load(f) + return dict(result) if isinstance(result, dict) else {} + + +def load_template(file_path: str) -> Dict[str, Any]: + """ + Load a CloudFormation template from a file, auto-detecting the format. + + This function automatically detects the file format based on the file + extension and loads the template accordingly: + - .json files are loaded as JSON + - .yaml, .yml, and .template files are loaded as YAML + + Args: + file_path: Path to the template file. + + Returns: + The template as a Python dictionary. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file extension is not recognized. + json.JSONDecodeError: If a JSON file contains invalid JSON. + yaml.YAMLError: If a YAML file contains invalid YAML. + IsADirectoryError: If the path points to a directory. + + Example: + >>> # Auto-detects JSON format + >>> template = load_template("template.json") + >>> + >>> # Auto-detects YAML format + >>> template = load_template("template.yaml") + >>> + >>> result = process_template(template) + + Requirements: + - 12.4: Support loading templates from JSON and YAML files + """ + # Get the file extension (lowercase for case-insensitive comparison) + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == ".json": + return load_template_from_json(file_path) + elif ext in (".yaml", ".yml", ".template"): + return load_template_from_yaml(file_path) + else: + raise ValueError( + f"Unrecognized file extension '{ext}'. " f"Supported extensions are: .json, .yaml, .yml, .template" + ) diff --git a/samcli/lib/cfn_language_extensions/exceptions.py b/samcli/lib/cfn_language_extensions/exceptions.py new file mode 100644 index 00000000000..edc5312127c --- /dev/null +++ b/samcli/lib/cfn_language_extensions/exceptions.py @@ -0,0 +1,169 @@ +""" +Exception classes for CloudFormation Language Extensions processing. + +This module provides exception classes that match the Kotlin implementation +for consistent error handling and messaging. +""" + +from typing import Optional + + +class InvalidTemplateException(Exception): + """ + Raised when template processing fails due to invalid template structure or content. + + This exception supports cause chaining to preserve the original exception + that triggered the error. + + Attributes: + cause: The original exception that caused this error, if any. + + Example: + >>> try: + ... # Some operation that fails + ... raise KeyError("missing_key") + ... except KeyError as e: + ... raise InvalidTemplateException("Mapping lookup failed", cause=e) + """ + + def __init__(self, message: str, cause: Optional[Exception] = None): + """ + Initialize the exception with a message and optional cause. + + Args: + message: The error message describing what went wrong. + cause: The original exception that caused this error, if any. + """ + super().__init__(message) + self.cause = cause + + def __str__(self) -> str: + """Return string representation including cause if present.""" + if self.cause: + return f"{self.args[0]} (caused by: {self.cause})" + return str(self.args[0]) + + +class UnresolvableReferenceError(Exception): + """ + Raised when a reference cannot be resolved in the current context. + + This exception is used during partial resolution mode to signal that + a reference (e.g., Fn::Ref to a resource, Fn::GetAtt) cannot be + resolved locally and should be preserved in the output. + + Attributes: + reference_type: The type of reference (e.g., "Ref", "Fn::GetAtt"). + reference_target: The target of the reference (e.g., resource logical ID). + + Example: + >>> raise UnresolvableReferenceError("Ref", "MyBucket") + UnresolvableReferenceError: Cannot resolve Ref to 'MyBucket' + """ + + def __init__(self, reference_type: str, reference_target: str): + """ + Initialize the exception with reference details. + + Args: + reference_type: The type of reference (e.g., "Ref", "Fn::GetAtt"). + reference_target: The target of the reference. + """ + self.reference_type = reference_type + self.reference_target = reference_target + super().__init__(f"Cannot resolve {reference_type} to '{reference_target}'") + + +class PublicFacingErrorMessages: + """ + Error messages matching the Kotlin implementation. + + This class provides standardized error messages for consistency + with the original CloudFormation Language Extensions implementation. + All error messages are designed to be user-friendly and actionable. + """ + + # Static error messages + INTERNAL_FAILURE = "Internal Failure" + INVALID_INPUT = "Invalid input passed to the AWS::LanguageExtensions Transform" + UNRESOLVED_CONDITIONS = "Unable to resolve Conditions section" + ERROR_PARSING_TEMPLATE = "Error parsing the template" + + @staticmethod + def not_supported_for_policies(logical_id: str) -> str: + """ + Generate error message for unsupported policy references. + + Args: + logical_id: The logical ID that is not supported (e.g., "AWS::NoValue"). + + Returns: + Error message indicating the logical ID is not supported for policies. + """ + return f"{logical_id} is not supported for DeletionPolicy or UpdateReplacePolicy" + + @staticmethod + def unresolved_policy(attr_name: str, logical_id: str) -> str: + """ + Generate error message for unresolved policy expressions. + + Args: + attr_name: The policy attribute name (e.g., "DeletionPolicy"). + logical_id: The resource logical ID. + + Returns: + Error message indicating the policy expression is unsupported. + """ + return f"Unsupported expression for {attr_name} in resource {logical_id}" + + @staticmethod + def resolution_error(logical_id: str) -> str: + """ + Generate error message for resource resolution errors. + + Args: + logical_id: The resource logical ID that failed to resolve. + + Returns: + Error message indicating a resolution error occurred. + """ + return f"Error resolving resource {logical_id} in template" + + @staticmethod + def resolve_type_mismatch(fn_type: str) -> str: + """ + Generate error message for type mismatches during resolution. + + Args: + fn_type: The intrinsic function type (e.g., "Fn::Length"). + + Returns: + Error message indicating a type mismatch occurred. + """ + return f"{fn_type} resolve value type mismatch" + + @staticmethod + def invalid_policy_string(attr_name: str) -> str: + """ + Generate error message for invalid policy string values. + + Args: + attr_name: The policy attribute name (e.g., "DeletionPolicy"). + + Returns: + Error message indicating policy members must be strings. + """ + return f"Every {attr_name} member must be a string" + + @staticmethod + def layout_incorrect(fn_name: str) -> str: + """ + Generate error message for incorrect intrinsic function layout. + + Args: + fn_name: The intrinsic function name (e.g., "Fn::Length"). + + Returns: + Error message indicating the function layout is incorrect. + """ + return f"{fn_name} layout is incorrect" diff --git a/samcli/lib/cfn_language_extensions/models.py b/samcli/lib/cfn_language_extensions/models.py new file mode 100644 index 00000000000..32a278e4d23 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/models.py @@ -0,0 +1,165 @@ +""" +Data classes and enums for CloudFormation Language Extensions processing. + +This module provides the core data structures used throughout the template +processing pipeline, including context objects, parsed template representation, +and configuration enums. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + # Avoid circular imports - ParsedTemplate is defined in this module + pass + + +class ResolutionMode(Enum): + """ + Controls how unresolvable references are handled during template processing. + + Attributes: + FULL: Raise an error when encountering unresolvable references. + Use this mode when you need complete resolution of all references. + PARTIAL: Preserve unresolvable references in the output. + Use this mode for SAM integration where resource references + cannot be resolved locally. + + Example: + >>> context = TemplateProcessingContext( + ... fragment=template, + ... resolution_mode=ResolutionMode.PARTIAL + ... ) + """ + + FULL = "full" + PARTIAL = "partial" + + +@dataclass +class PseudoParameterValues: + """ + AWS pseudo-parameter values for template resolution. + + This class holds values for AWS pseudo-parameters that can be provided + to simulate different deployment contexts during local template processing. + + Attributes: + region: The AWS region (e.g., "us-east-1"). Required. + account_id: The AWS account ID (e.g., "123456789012"). Required. + stack_id: The CloudFormation stack ID. Optional. + stack_name: The CloudFormation stack name. Optional. + notification_arns: List of SNS topic ARNs for stack notifications. Optional. + partition: AWS partition (e.g., "aws", "aws-cn", "aws-us-gov"). + Derived from region if not provided. + url_suffix: AWS URL suffix (e.g., "amazonaws.com", "amazonaws.com.cn"). + Derived from region if not provided. + + Example: + >>> pseudo_params = PseudoParameterValues( + ... region="us-west-2", + ... account_id="123456789012", + ... stack_name="my-stack" + ... ) + + Note: + The partition and url_suffix can be automatically derived from the region + during template processing if not explicitly provided. + """ + + region: str + account_id: str + stack_id: Optional[str] = None + stack_name: Optional[str] = None + notification_arns: Optional[List[str]] = None + partition: Optional[str] = None + url_suffix: Optional[str] = None + + +@dataclass +class ParsedTemplate: + """ + Structured representation of a CloudFormation template. + + This class provides a typed representation of a CloudFormation template + with all standard sections. Missing optional sections (Parameters, Mappings, + Conditions, Outputs) are initialized as empty dictionaries. + + Note: The resources field can be None to allow validation to detect + when the Resources section is missing or explicitly null in the template. + + Attributes: + aws_template_format_version: The template format version string. + description: The template description. + parameters: Template parameters section. + mappings: Template mappings section. + conditions: Template conditions section. + resources: Template resources section (can be None for validation). + outputs: Template outputs section. + transform: Transform declaration (string or list of strings). + + Example: + >>> parsed = ParsedTemplate( + ... aws_template_format_version="2010-09-09", + ... description="My template", + ... resources={"MyBucket": {"Type": "AWS::S3::Bucket"}} + ... ) + """ + + aws_template_format_version: Optional[str] = None + description: Optional[str] = None + parameters: Dict[str, Any] = field(default_factory=dict) + mappings: Dict[str, Any] = field(default_factory=dict) + conditions: Dict[str, Any] = field(default_factory=dict) + resources: Optional[Dict[str, Any]] = None # Can be None for validation + outputs: Dict[str, Any] = field(default_factory=dict) + transform: Optional[Any] = None + + +@dataclass +class TemplateProcessingContext: + """ + Mutable context passed through the processing pipeline. + + This class holds all the state needed during template processing, + including the template fragment being processed, parameter values, + pseudo-parameter values, and intermediate processing results. + + Attributes: + fragment: The template content being processed as a dictionary. + parameter_values: Values for template parameters. + pseudo_parameters: AWS pseudo-parameter values for resolution. + resolution_mode: How to handle unresolvable references. + parsed_template: Structured template representation (set during processing). + resolved_conditions: Evaluated condition values (set during processing). + request_id: Unique identifier for the processing request. + + Example: + >>> context = TemplateProcessingContext( + ... fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}, + ... parameter_values={"Environment": "prod"}, + ... pseudo_parameters=PseudoParameterValues( + ... region="us-east-1", + ... account_id="123456789012" + ... ), + ... resolution_mode=ResolutionMode.PARTIAL + ... ) + + Note: + The parsed_template and resolved_conditions fields are populated + during processing by the respective processors in the pipeline. + """ + + fragment: Dict[str, Any] + parameter_values: Dict[str, Any] = field(default_factory=dict) + pseudo_parameters: Optional[PseudoParameterValues] = None + resolution_mode: ResolutionMode = ResolutionMode.PARTIAL + + # Set during processing + parsed_template: Optional[ParsedTemplate] = None + resolved_conditions: Dict[str, bool] = field(default_factory=dict) + request_id: str = "" + + # Track conditions being evaluated for circular reference detection + _evaluating_conditions: set = field(default_factory=set) diff --git a/samcli/lib/cfn_language_extensions/pipeline.py b/samcli/lib/cfn_language_extensions/pipeline.py new file mode 100644 index 00000000000..d56d2865dfc --- /dev/null +++ b/samcli/lib/cfn_language_extensions/pipeline.py @@ -0,0 +1,147 @@ +""" +Processing pipeline for CloudFormation Language Extensions. + +This module provides the core pipeline infrastructure for processing +CloudFormation templates through a sequence of processors. + +The pipeline pattern allows for: +- Ordered execution of template processors +- Exception propagation (stop on first error) +- Extensibility through custom processors +""" + +from typing import Any, Dict, List, Protocol, runtime_checkable + +from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + + +@runtime_checkable +class TemplateProcessor(Protocol): + """ + Interface for template processors in the pipeline. + + Template processors are components that transform or validate + CloudFormation templates. Each processor receives a mutable + context object and modifies it in place. + + Implementations should: + - Modify the context.fragment or other context fields as needed + - Raise InvalidTemplateException for validation errors + - Not catch exceptions from other processors + + Example: + >>> class MyProcessor: + ... def process_template(self, context: TemplateProcessingContext) -> None: + ... # Modify context.fragment in place + ... context.fragment["Processed"] = True + """ + + def process_template(self, context: TemplateProcessingContext) -> None: + """ + Process the template, modifying context in place. + + Args: + context: The mutable template processing context containing + the template fragment and processing state. + + Raises: + InvalidTemplateException: If the template is invalid or + processing fails. + """ + ... + + +class ProcessingPipeline: + """ + Executes a sequence of processors on a template. + + The ProcessingPipeline accepts a list of TemplateProcessor instances + and executes them in order on a TemplateProcessingContext. Each + processor modifies the context in place. + + Exception Handling: + If any processor raises an exception (typically InvalidTemplateException), + the pipeline stops execution immediately and propagates the exception + to the caller. Subsequent processors are NOT executed. + + Attributes: + _processors: The ordered list of processors to execute. + + Example: + >>> from samcli.lib.cfn_language_extensions.pipeline import ProcessingPipeline + >>> from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + >>> + >>> # Create processors + >>> processors = [ParserProcessor(), ResolverProcessor()] + >>> pipeline = ProcessingPipeline(processors) + >>> + >>> # Process a template + >>> context = TemplateProcessingContext( + ... fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + ... ) + >>> result = pipeline.process_template(context) + + Requirements: + - 1.1: Accept a list of Template_Processors and execute them in order + - 1.2: Pass a Template_Processing_Context through each processor + - 1.3: Return the processed template fragment after all processors complete + - 1.4: Propagate exceptions without continuing to subsequent processors + """ + + def __init__(self, processors: List[TemplateProcessor]) -> None: + """ + Initialize the pipeline with a list of processors. + + Args: + processors: An ordered list of TemplateProcessor instances. + Processors will be executed in the order provided. + + Example: + >>> pipeline = ProcessingPipeline([ + ... ParserProcessor(), + ... ForEachProcessor(), + ... IntrinsicResolver(), + ... ]) + """ + self._processors = processors + + def process_template(self, context: TemplateProcessingContext) -> Dict[str, Any]: + """ + Run all processors and return the processed fragment. + + Executes each processor in order, passing the context through + the pipeline. Each processor modifies the context in place. + + Args: + context: The template processing context containing the + template fragment and processing configuration. + + Returns: + The processed template fragment as a dictionary. + + Raises: + InvalidTemplateException: If any processor raises this exception. + The exception is propagated without + executing subsequent processors. + + Example: + >>> context = TemplateProcessingContext( + ... fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + ... ) + >>> result = pipeline.process_template(context) + >>> print(result) + {'Resources': {'MyBucket': {'Type': 'AWS::S3::Bucket'}}} + """ + for processor in self._processors: + processor.process_template(context) + return context.fragment + + @property + def processors(self) -> List[TemplateProcessor]: + """ + Get the list of processors in the pipeline. + + Returns: + A copy of the processor list to prevent external modification. + """ + return list(self._processors) diff --git a/samcli/lib/cfn_language_extensions/processors/__init__.py b/samcli/lib/cfn_language_extensions/processors/__init__.py new file mode 100644 index 00000000000..f74dd929703 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/__init__.py @@ -0,0 +1,19 @@ +""" +Template processors for CloudFormation Language Extensions. + +This module provides the various processors used in the template +processing pipeline, including parsing, intrinsic function resolution, +and policy validation. +""" + +from samcli.lib.cfn_language_extensions.processors.deletion_policy import DeletionPolicyProcessor +from samcli.lib.cfn_language_extensions.processors.foreach import ForEachProcessor +from samcli.lib.cfn_language_extensions.processors.parsing import TemplateParsingProcessor +from samcli.lib.cfn_language_extensions.processors.update_replace_policy import UpdateReplacePolicyProcessor + +__all__ = [ + "TemplateParsingProcessor", + "ForEachProcessor", + "DeletionPolicyProcessor", + "UpdateReplacePolicyProcessor", +] diff --git a/samcli/lib/cfn_language_extensions/processors/deletion_policy.py b/samcli/lib/cfn_language_extensions/processors/deletion_policy.py new file mode 100644 index 00000000000..aba2d3d7d30 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/deletion_policy.py @@ -0,0 +1,45 @@ +""" +DeletionPolicy processor for CloudFormation Language Extensions. + +This module provides the DeletionPolicyProcessor which validates and resolves +DeletionPolicy attributes on CloudFormation resources. + +Requirements: + - 7.1: WHEN a resource has a DeletionPolicy attribute, THEN THE Processor + SHALL resolve any parameter references in the policy value + - 7.3: WHEN DeletionPolicy contains a Ref to a parameter, THEN THE Processor + SHALL substitute the parameter's value + - 7.4: WHEN DeletionPolicy resolves to AWS::NoValue, THEN THE Processor + SHALL raise an Invalid_Template_Exception + - 7.5: WHEN DeletionPolicy does not resolve to a valid string value, THEN + THE Processor SHALL raise an Invalid_Template_Exception +""" + +from samcli.lib.cfn_language_extensions.processors.resource_policy import ResourcePolicyProcessor + + +class DeletionPolicyProcessor(ResourcePolicyProcessor): + """ + Validates and resolves DeletionPolicy attributes on resources. + + Valid DeletionPolicy values are: "Delete", "Retain", "Snapshot" + + Example: + >>> processor = DeletionPolicyProcessor() + >>> context = TemplateProcessingContext( + ... fragment={ + ... "Resources": { + ... "MyBucket": { + ... "Type": "AWS::S3::Bucket", + ... "DeletionPolicy": {"Ref": "PolicyParam"} + ... } + ... } + ... }, + ... parameter_values={"PolicyParam": "Retain"} + ... ) + >>> processor.process_template(context) + >>> print(context.fragment["Resources"]["MyBucket"]["DeletionPolicy"]) + "Retain" + """ + + POLICY_NAME = "DeletionPolicy" diff --git a/samcli/lib/cfn_language_extensions/processors/foreach.py b/samcli/lib/cfn_language_extensions/processors/foreach.py new file mode 100644 index 00000000000..06aa5856c5c --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/foreach.py @@ -0,0 +1,1084 @@ +""" +ForEach processor for CloudFormation Language Extensions. + +This module provides the ForEachProcessor which handles Fn::ForEach loops +in CloudFormation templates. Fn::ForEach allows generating multiple resources, +conditions, or outputs from a template, reducing duplication. + +The processor handles: +- Detection of Fn::ForEach:: prefixed keys in Resources, Outputs, and Conditions +- Validation of ForEach structure (identifier, collection, body) +- Resolution of collections containing intrinsic functions +""" + +from typing import Any, Dict, List, Optional + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + + +class ForEachProcessor: + """ + Expands Fn::ForEach loops in Resources, Outputs, and Conditions sections. + + Fn::ForEach is a CloudFormation language extension that allows generating + multiple resources, conditions, or outputs from a single template definition. + The syntax is: + + "Fn::ForEach::UniqueLoopName": [ + "Identifier", + ["Item1", "Item2", ...], + { + "OutputKey${Identifier}": { ... template body ... } + } + ] + + This processor: + 1. Detects Fn::ForEach:: prefixed keys in supported sections + 2. Validates the ForEach structure (identifier, collection, body) + 3. Validates nesting depth does not exceed the maximum allowed (5 levels) + 4. Resolves collections that contain intrinsic functions + + Note: The actual loop expansion logic is implemented in task 12.2. + This task (12.1) focuses on detection, validation, and collection resolution. + + Requirements: + - 6.7: WHEN Fn::ForEach has invalid layout (wrong number of arguments, + invalid types), THEN THE Resolver SHALL raise an Invalid_Template_Exception + - 6.8: WHEN Fn::ForEach collection contains a Ref to a parameter, + THEN THE Resolver SHALL resolve the parameter value before iteration + - 18.2: WHEN a template contains 5 or fewer levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL process the template successfully + - 18.3: WHEN a template contains more than 5 levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL raise an error before processing + + Example: + >>> processor = ForEachProcessor() + >>> context = TemplateProcessingContext( + ... fragment={ + ... "Resources": { + ... "Fn::ForEach::Topics": [ + ... "TopicName", + ... ["Alerts", "Notifications"], + ... {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}} + ... ] + ... } + ... } + ... ) + >>> processor.process_template(context) + """ + + FOREACH_PREFIX = "Fn::ForEach::" + MAX_FOREACH_NESTING_DEPTH = 5 + + def __init__(self, intrinsic_resolver: Optional[Any] = None) -> None: + """ + Initialize the ForEachProcessor. + + Args: + intrinsic_resolver: Optional IntrinsicResolver instance for resolving + collections that contain intrinsic functions. + If None, collections with intrinsics will not be + resolved (useful for validation-only scenarios). + """ + self._intrinsic_resolver = intrinsic_resolver + + def process_template(self, context: TemplateProcessingContext) -> None: + """ + Process the template by validating and preparing Fn::ForEach constructs. + + This method scans the Conditions, Resources, and Outputs sections for + Fn::ForEach:: prefixed keys and validates their structure. Collections + containing intrinsic functions are resolved if an intrinsic_resolver + is available. + + Also handles Fn::ForEach within resource Properties sections. + + If a section becomes empty after ForEach expansion (e.g., empty collection), + the section is removed from the template. + + Args: + context: The mutable template processing context containing + the template fragment to process. + + Raises: + InvalidTemplateException: If any ForEach construct has invalid structure, + or if nesting depth exceeds the maximum allowed. + """ + # Validate nesting depth before processing (Requirement 18.2, 18.3) + self._validate_foreach_nesting_depth(context.fragment) + + # Process conditions first (they may be referenced by resources) + if "Conditions" in context.fragment: + processed = self._process_section(context.fragment.get("Conditions", {}), context, "Conditions") + if processed: + context.fragment["Conditions"] = processed + else: + # Remove empty Conditions section + del context.fragment["Conditions"] + + # Process resources (including ForEach within Properties) + if "Resources" in context.fragment: + context.fragment["Resources"] = self._process_resources_section( + context.fragment.get("Resources", {}), context + ) + + # Process outputs + if "Outputs" in context.fragment: + processed = self._process_section(context.fragment.get("Outputs", {}), context, "Outputs") + if processed: + context.fragment["Outputs"] = processed + else: + # Remove empty Outputs section + del context.fragment["Outputs"] + + def _process_resources_section(self, section: Any, context: TemplateProcessingContext) -> Any: + """ + Process the Resources section for Fn::ForEach constructs. + + This handles both: + 1. Top-level ForEach that generates multiple resources + 2. ForEach within resource Properties that generates multiple properties + + Args: + section: The Resources section dictionary to process. + context: The template processing context. + + Returns: + The processed Resources section with ForEach constructs expanded. + """ + if not isinstance(section, dict): + return section + + result = {} + + for key, value in section.items(): + if self._is_foreach_key(key): + # Top-level ForEach - generates multiple resources + self._validate_foreach(key, value, context) + expanded = self._expand_foreach(key, value, context) + # Process each expanded resource for nested ForEach in Properties + for res_key, res_value in expanded.items(): + if isinstance(res_value, dict) and "Properties" in res_value: + processed_resource = dict(res_value) + processed_resource["Properties"] = self._process_properties(res_value["Properties"], context) + result[res_key] = processed_resource + else: + result[res_key] = res_value + # Regular resource - check for ForEach in Properties + elif isinstance(value, dict) and "Properties" in value: + processed_resource = dict(value) + processed_resource["Properties"] = self._process_properties(value["Properties"], context) + result[key] = processed_resource + else: + result[key] = value + + return result + + def _process_properties(self, properties: Any, context: TemplateProcessingContext) -> Any: + """ + Process resource Properties for Fn::ForEach constructs. + + This handles Fn::ForEach within Properties that generates multiple + property key-value pairs. + + Args: + properties: The Properties dictionary to process. + context: The template processing context. + + Returns: + The processed Properties with ForEach constructs expanded. + """ + if not isinstance(properties, dict): + return properties + + result = {} + + for key, value in properties.items(): + if self._is_foreach_key(key): + # ForEach in Properties - generates multiple properties + self._validate_foreach(key, value, context) + expanded = self._expand_foreach(key, value, context) + result.update(expanded) + else: + result[key] = value + + return result + + def _process_section(self, section: Any, context: TemplateProcessingContext, section_name: str) -> Any: + """ + Process a template section for Fn::ForEach constructs. + + This method iterates through the section, validates any + Fn::ForEach:: prefixed keys found, and expands them into + multiple outputs. + + Args: + section: The template section dictionary to process. + context: The template processing context. + section_name: Name of the section being processed (for error messages). + + Returns: + The processed section dictionary with ForEach constructs expanded. + + Raises: + InvalidTemplateException: If any ForEach construct is invalid. + + Requirements: + - 6.1: Fn::ForEach in Resources SHALL expand to multiple resources + - 6.2: Fn::ForEach in Outputs SHALL expand to multiple outputs + - 6.3: Fn::ForEach in Conditions SHALL expand to multiple conditions + """ + if not isinstance(section, dict): + return section + + result = {} + + for key, value in section.items(): + if self._is_foreach_key(key): + # Validate the ForEach structure + self._validate_foreach(key, value, context) + # Expand the ForEach and merge results + expanded = self._expand_foreach(key, value, context) + result.update(expanded) + else: + result[key] = value + + return result + + def _is_foreach_key(self, key: str) -> bool: + """ + Check if a key represents a Fn::ForEach construct. + + Fn::ForEach keys have the format "Fn::ForEach::UniqueLoopName" + where UniqueLoopName is a user-defined identifier for the loop. + + Args: + key: The key to check. + + Returns: + True if the key starts with "Fn::ForEach::", False otherwise. + """ + return isinstance(key, str) and key.startswith(self.FOREACH_PREFIX) + + def _validate_foreach_nesting_depth(self, template: Dict[str, Any]) -> None: + """ + Validate that Fn::ForEach nesting does not exceed the maximum allowed depth. + + CloudFormation enforces a maximum nesting depth of 5 for Fn::ForEach loops. + This method validates the template before processing to provide early feedback. + + Args: + template: The template dictionary to validate. + + Raises: + InvalidTemplateException: If the nesting depth exceeds the maximum allowed. + + Requirements: + - 18.2: WHEN a template contains 5 or fewer levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL process the template successfully + - 18.3: WHEN a template contains more than 5 levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL raise an error before processing + - 18.4: WHEN the nested loop limit is exceeded, THE error message SHALL clearly + indicate that the maximum nesting depth of 5 has been exceeded + - 18.5: WHEN the nested loop limit is exceeded, THE error message SHALL indicate + the actual nesting depth found in the template + """ + # Check all sections that can contain Fn::ForEach + max_depth = 0 + for section_name in ("Resources", "Conditions", "Outputs"): + section = template.get(section_name, {}) + if isinstance(section, dict): + section_depth = self._calculate_max_foreach_depth(section, current_depth=0) + max_depth = max(max_depth, section_depth) + + if max_depth > self.MAX_FOREACH_NESTING_DEPTH: + raise InvalidTemplateException( + f"Fn::ForEach nesting depth of {max_depth} exceeds the maximum allowed depth " + f"of {self.MAX_FOREACH_NESTING_DEPTH}. CloudFormation supports up to " + f"{self.MAX_FOREACH_NESTING_DEPTH} nested Fn::ForEach loops." + ) + + def _calculate_max_foreach_depth(self, node: Any, current_depth: int) -> int: + """ + Recursively calculate the maximum Fn::ForEach nesting depth. + + This method traverses the template structure to find the maximum depth + of nested Fn::ForEach loops. + + Args: + node: The current node in the template structure to analyze. + current_depth: The current nesting depth (0 at the top level). + + Returns: + The maximum nesting depth found in this subtree. + + Requirements: + - 18.1: WHEN a template contains Fn::ForEach loops nested within each other, + THE SAM_CLI SHALL count the nesting depth starting from 1 for the + outermost loop + """ + if isinstance(node, dict): + max_child_depth = current_depth + for key, value in node.items(): + if self._is_foreach_key(key): + # Found a ForEach - increment depth and check its body + # The body is the third element of the ForEach array + if isinstance(value, list) and len(value) >= 3: + foreach_body = value[2] + child_depth = self._calculate_max_foreach_depth(foreach_body, current_depth + 1) + max_child_depth = max(max_child_depth, child_depth) + else: + # Invalid ForEach structure - just count this level + max_child_depth = max(max_child_depth, current_depth + 1) + else: + # Not a ForEach key - recurse into the value + child_depth = self._calculate_max_foreach_depth(value, current_depth) + max_child_depth = max(max_child_depth, child_depth) + return max_child_depth + elif isinstance(node, list): + if not node: + return current_depth + return max(self._calculate_max_foreach_depth(item, current_depth) for item in node) + else: + # Primitive value - return current depth + return current_depth + + def _validate_foreach( + self, key: str, value: Any, context: TemplateProcessingContext, parent_identifiers: Optional[List[str]] = None + ) -> None: + """ + Validate a Fn::ForEach construct. + + The ForEach value must be a list with exactly 3 elements: + 1. identifier: A non-empty string OR a list of non-empty strings (for list-of-lists) + Can contain intrinsics like {"Ref": "ParamName"} + 2. collection: A list of values to iterate over (or an intrinsic that resolves to a list) + For list-of-lists, items can also contain intrinsics + 3. template_body: A dictionary containing the template to expand + + For list-of-lists format: + - identifier is a list like ["LogicalId", "TopicId"] + - collection is a list of lists like [[1, "1"], [2, "2"]] + - Each inner list provides values for the corresponding identifiers + + Args: + key: The ForEach key (e.g., "Fn::ForEach::Topics"). + value: The ForEach value (should be a 3-element list). + context: The template processing context. + parent_identifiers: List of identifiers from parent ForEach loops (for conflict detection). + + Raises: + InvalidTemplateException: If the ForEach structure is invalid. + + Requirements: + - 6.6: Raise Invalid_Template_Exception for identifier conflicts + - 6.7: Raise Invalid_Template_Exception for invalid layout + """ + if parent_identifiers is None: + parent_identifiers = [] + + # Value must be a list + if not isinstance(value, list): + raise InvalidTemplateException(f"{key} layout is incorrect") + + # Must have exactly 3 elements + if len(value) != 3: + raise InvalidTemplateException(f"{key} layout is incorrect") + + identifier = value[0] + collection = value[1] + template_body = value[2] + + # Resolve and validate identifier + identifiers = self._resolve_identifiers(identifier, context) + if not identifiers: + raise InvalidTemplateException(f"{key} layout is incorrect") + + # Check for identifier conflicts with parameter names (Requirement 6.6) + for ident in identifiers: + self._check_identifier_conflicts(ident, key, context, parent_identifiers) + + # Validate template_body: must be a dictionary + if not isinstance(template_body, dict): + raise InvalidTemplateException(f"{key} layout is incorrect") + + # Resolve collection if it contains intrinsics + resolved_collection = self._resolve_collection(collection, context) + + # Validate resolved collection: must be a list + if not isinstance(resolved_collection, list): + raise InvalidTemplateException(f"{key} layout is incorrect") + + # For list-of-lists format, resolve each item and validate + if len(identifiers) > 1: + resolved_items = [] + for item in resolved_collection: + resolved_item = self._resolve_collection_item(item, context) + if not isinstance(resolved_item, list) or len(resolved_item) != len(identifiers): + raise InvalidTemplateException(f"{key} layout is incorrect") + resolved_items.append(resolved_item) + resolved_collection = resolved_items + + # Update the value with resolved identifier and collection for later expansion + value[0] = identifiers if len(identifiers) > 1 else identifiers[0] + value[1] = resolved_collection + + def _resolve_identifiers(self, identifier: Any, context: TemplateProcessingContext) -> List[str]: + """ + Resolve and validate identifiers. + + Identifiers can be: + - A string: "VariableName" + - A list of strings: ["LogicalId", "TopicId"] + - A list containing intrinsics: [{"Ref": "ParamName"}, "TopicId"] + + Args: + identifier: The identifier value to resolve. + context: The template processing context. + + Returns: + A list of resolved identifier strings. + """ + if isinstance(identifier, str): + if not identifier: + return [] + return [identifier] + elif isinstance(identifier, list): + resolved = [] + for item in identifier: + if isinstance(item, str): + if not item: + return [] + resolved.append(item) + elif isinstance(item, dict): + # Try to resolve intrinsic + resolved_item = self._resolve_intrinsic(item, context) + if not isinstance(resolved_item, str) or not resolved_item: + return [] + resolved.append(resolved_item) + else: + return [] + return resolved + else: + return [] + + def _resolve_intrinsic(self, value: Any, context: TemplateProcessingContext) -> Any: + """ + Resolve an intrinsic function value. + + Args: + value: The value to resolve. + context: The template processing context. + + Returns: + The resolved value. + """ + if self._intrinsic_resolver is not None: + return self._intrinsic_resolver.resolve_value(value) + + # Fallback: try to resolve Ref manually + if isinstance(value, dict) and len(value) == 1: + key = next(iter(value.keys())) + if key == "Ref": + ref_target = value["Ref"] + if isinstance(ref_target, str): + # Check parameter_values + if ref_target in context.parameter_values: + return context.parameter_values[ref_target] + + # Check parsed_template parameters + if context.parsed_template is not None and ref_target in context.parsed_template.parameters: + param_def = context.parsed_template.parameters[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + return param_def["Default"] + + return value + + def _resolve_collection_item(self, item: Any, context: TemplateProcessingContext) -> Any: + """ + Resolve a collection item that may contain intrinsics. + + For list-of-lists format, each item in the collection can be: + - A list of values: [1, "1"] + - An intrinsic that resolves to a list: {"Ref": "ValueList"} + + Args: + item: The collection item to resolve. + context: The template processing context. + + Returns: + The resolved collection item. + """ + if isinstance(item, list): + return item + + # Try to resolve intrinsic using the intrinsic resolver + if self._intrinsic_resolver is not None: + resolved = self._intrinsic_resolver.resolve_value(item) + # Handle CommaDelimitedList that was resolved to a string + if isinstance(resolved, str) and "," in resolved: + return [v.strip() for v in resolved.split(",")] + return resolved + + # Fallback: try to resolve Ref manually + if isinstance(item, dict) and len(item) == 1: + key = next(iter(item.keys())) + if key == "Ref": + ref_target = item["Ref"] + if isinstance(ref_target, str): + # Check parameter_values + if ref_target in context.parameter_values: + param_value = context.parameter_values[ref_target] + if isinstance(param_value, str): + return [v.strip() for v in param_value.split(",")] + return param_value + + # Check parsed_template parameters + if context.parsed_template is not None and ref_target in context.parsed_template.parameters: + param_def = context.parsed_template.parameters[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + default_value = param_def["Default"] + param_type = param_def.get("Type", "String") + if param_type == "CommaDelimitedList" and isinstance(default_value, str): + return [v.strip() for v in default_value.split(",")] + return default_value + + # Check fragment Parameters + if "Parameters" in context.fragment: + params = context.fragment.get("Parameters", {}) + if isinstance(params, dict) and ref_target in params: + param_def = params[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + default_value = param_def["Default"] + param_type = param_def.get("Type", "String") + if param_type == "CommaDelimitedList" and isinstance(default_value, str): + return [v.strip() for v in default_value.split(",")] + return default_value + + return item + + def _check_identifier_conflicts( + self, identifier: str, key: str, context: TemplateProcessingContext, parent_identifiers: List[str] + ) -> None: + """ + Check for identifier conflicts with parameter names and other loop identifiers. + + Args: + identifier: The loop variable identifier to check. + key: The ForEach key (for error messages). + context: The template processing context. + parent_identifiers: List of identifiers from parent ForEach loops. + + Raises: + InvalidTemplateException: If the identifier conflicts with a parameter name + or another loop identifier. + + Requirements: + - 6.6: WHEN Fn::ForEach identifier conflicts with an existing parameter name, + THEN THE Resolver SHALL raise an Invalid_Template_Exception + """ + # Check for conflict with parameter names + parameter_names = self._get_parameter_names(context) + if identifier in parameter_names: + raise InvalidTemplateException(f"{key}: identifier '{identifier}' conflicts with parameter name") + + # Check for conflict with parent loop identifiers + if identifier in parent_identifiers: + raise InvalidTemplateException(f"{key}: identifier '{identifier}' conflicts with another loop identifier") + + def _get_parameter_names(self, context: TemplateProcessingContext) -> set: + """ + Get all parameter names from the template. + + Args: + context: The template processing context. + + Returns: + A set of parameter names. + """ + parameter_names: set[str] = set() + + # Get parameters from the fragment + if "Parameters" in context.fragment: + parameters = context.fragment.get("Parameters", {}) + if isinstance(parameters, dict): + parameter_names.update(parameters.keys()) + + # Also check parsed_template if available + if context.parsed_template is not None and context.parsed_template.parameters: + parameter_names.update(context.parsed_template.parameters.keys()) + + return parameter_names + + def _expand_foreach( + self, + key: str, + value: List[Any], + context: TemplateProcessingContext, + parent_identifiers: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Expand a single Fn::ForEach construct into multiple outputs. + + This method iterates over the collection and for each item: + 1. Substitutes ${identifier} with the current item value in both keys and values + 2. Recursively expands any nested Fn::ForEach constructs + 3. Merges the expanded entries into the result + + Supports two formats: + 1. Simple format: identifier is a string, collection is a list of values + 2. List-of-lists format: identifier is a list of strings, collection is a list of lists + + Args: + key: The ForEach key (e.g., "Fn::ForEach::Topics"). + value: The ForEach value (a 3-element list: [identifier, collection, body]). + context: The template processing context. + parent_identifiers: List of identifiers from parent ForEach loops (for conflict detection). + + Returns: + A dictionary containing all expanded entries. + + Requirements: + - 6.1: Fn::ForEach in Resources SHALL expand to multiple resources + - 6.2: Fn::ForEach in Outputs SHALL expand to multiple outputs + - 6.3: Fn::ForEach in Conditions SHALL expand to multiple conditions + - 6.4: Nested Fn::ForEach SHALL expand recursively + - 6.5: Collection items SHALL be iterated in order + - 6.9: Identifier SHALL be substituted in both keys and values + + Example: + >>> # Input: + >>> # "Fn::ForEach::Topics": [ + >>> # "TopicName", + >>> # ["Alerts", "Notifications"], + >>> # {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}} + >>> # ] + >>> # Output: + >>> # { + >>> # "TopicAlerts": {"Type": "AWS::SNS::Topic"}, + >>> # "TopicNotifications": {"Type": "AWS::SNS::Topic"} + >>> # } + """ + if parent_identifiers is None: + parent_identifiers = [] + + identifier = value[0] + collection = value[1] # Already resolved in _validate_foreach + template_body = value[2] + + # Determine if this is list-of-lists format + if isinstance(identifier, list): + identifiers = identifier + is_list_of_lists = True + else: + identifiers = [identifier] + is_list_of_lists = False + + # Track current identifiers for nested loops + current_identifiers = parent_identifiers + identifiers + + result = {} + + # Iterate over collection items in order (Requirement 6.5) + for item in collection: + # Get values for each identifier + if is_list_of_lists: + # item is a list of values, one for each identifier + item_values = [self._to_string(v) for v in item] + else: + # item is a single value + item_values = [self._to_string(item)] + + # Substitute all identifiers in the template body (Requirement 6.9) + expanded = template_body + for ident, item_str in zip(identifiers, item_values): + expanded = self._substitute_identifier(expanded, ident, item_str) + + # Recursively expand any nested ForEach constructs (Requirement 6.4) + if isinstance(expanded, dict): + expanded = self._expand_nested_foreach(expanded, context, current_identifiers) + + # Merge expanded entries into result + result.update(expanded) + + return result + + def _to_string(self, value: Any) -> str: + """ + Convert a value to string for substitution. + + Handles special cases: + - Booleans are converted to lowercase ("true"/"false") to match Kotlin behavior + - Other types use standard str() conversion + + Args: + value: The value to convert. + + Returns: + The string representation of the value. + """ + if isinstance(value, bool): + # Use lowercase for booleans to match Kotlin/JSON behavior + return "true" if value else "false" + return str(value) + + def _expand_nested_foreach( + self, + template: Dict[str, Any], + context: TemplateProcessingContext, + parent_identifiers: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Recursively expand nested Fn::ForEach constructs in a template. + + This method processes a dictionary that may contain nested ForEach + constructs and expands them recursively. + + Args: + template: A dictionary that may contain nested ForEach constructs. + context: The template processing context. + parent_identifiers: List of identifiers from parent ForEach loops (for conflict detection). + + Returns: + The template with all nested ForEach constructs expanded. + + Requirements: + - 6.4: Nested Fn::ForEach SHALL expand recursively + - 6.6: Raise Invalid_Template_Exception for identifier conflicts + """ + if parent_identifiers is None: + parent_identifiers = [] + + result = {} + + for key, value in template.items(): + if self._is_foreach_key(key): + # Validate and expand the nested ForEach with parent identifiers + self._validate_foreach(key, value, context, parent_identifiers) + expanded = self._expand_foreach(key, value, context, parent_identifiers) + result.update(expanded) + else: + result[key] = value + + return result + + def _substitute_identifier(self, template: Any, identifier: str, value: str) -> Any: + """ + Substitute ${identifier} and {"Ref": "identifier"} with value throughout the template. + + This method recursively processes the template structure and replaces + all occurrences of ${identifier} and {"Ref": "identifier"} with the + provided value. The substitution is performed in: + - String values (for ${identifier} syntax) + - Dictionary keys (for ${identifier} syntax) + - Dictionary values (for both syntaxes) + - List items + - Ref intrinsic functions referencing the identifier + + Args: + template: The template structure to process (can be any type). + identifier: The identifier to substitute (without ${} wrapper). + value: The value to substitute in place of ${identifier}. + + Returns: + The template with all ${identifier} and {"Ref": "identifier"} occurrences replaced. + + Requirements: + - 6.9: Identifier SHALL be substituted in both keys and values + + Example: + >>> _substitute_identifier( + ... {"Topic${Name}": {"TopicName": "${Name}Topic"}}, + ... "Name", + ... "Alerts" + ... ) + {"TopicAlerts": {"TopicName": "AlertsTopic"}} + + >>> _substitute_identifier( + ... {"Fn::Equals": ["1", {"Ref": "Name"}]}, + ... "Name", + ... "1" + ... ) + {"Fn::Equals": ["1", "1"]} + """ + if isinstance(template, str): + # Replace ${identifier} with value in strings + return template.replace(f"${{{identifier}}}", value) + elif isinstance(template, dict): + # Check if this is a Ref to the loop identifier + if len(template) == 1 and "Ref" in template: + ref_target = template["Ref"] + if ref_target == identifier: + # Replace Ref to loop identifier with the value + return value + + # Process both keys and values in dictionaries + return { + self._substitute_identifier(k, identifier, value): self._substitute_identifier(v, identifier, value) + for k, v in template.items() + } + elif isinstance(template, list): + # Process each item in lists + return [self._substitute_identifier(item, identifier, value) for item in template] + else: + # Return primitives (int, float, bool, None) unchanged + return template + + def _resolve_collection(self, collection: Any, context: TemplateProcessingContext) -> Any: + """ + Resolve a collection that may contain intrinsic functions. + + If the collection is an intrinsic function (e.g., Ref to a parameter), + it is resolved using the intrinsic resolver. If no resolver is available + or the collection is already a list, it is returned as-is. + + Cloud-dependent intrinsics (Fn::GetAtt, Fn::ImportValue) and dynamic + references (SSM/Secrets Manager) are not supported and will raise + an error with a helpful workaround message. + + Args: + collection: The collection value to resolve. + context: The template processing context. + + Returns: + The resolved collection value. + + Raises: + InvalidTemplateException: If the collection contains cloud-dependent + intrinsics that cannot be resolved locally. + + Requirements: + - 5.1: Raise error for Fn::GetAtt in collection + - 5.2: Raise error for Fn::ImportValue in collection + - 5.3: Raise error for SSM/Secrets Manager dynamic references + - 5.6: Static list collections work correctly + - 5.7: Parameter references work correctly + - 6.8: Resolve parameter references in collections before iteration + """ + # If collection is already a list, check for cloud-dependent values in items + if isinstance(collection, list): + self._validate_collection_items(collection) + return collection + + # Check for cloud-dependent intrinsics before attempting resolution + self._validate_collection_resolvability(collection) + + # If we have an intrinsic resolver, try to resolve the collection + if self._intrinsic_resolver is not None: + resolved = self._intrinsic_resolver.resolve_value(collection) + # Handle CommaDelimitedList that was resolved to a string + if isinstance(resolved, str) and "," in resolved: + return [item.strip() for item in resolved.split(",")] + return resolved + + # If collection is a dict (potential intrinsic), try to resolve via context + if isinstance(collection, dict) and len(collection) == 1: + key = next(iter(collection.keys())) + + # Handle Ref to parameters + if key == "Ref": + ref_target = collection["Ref"] + if isinstance(ref_target, str): + # Check parameter_values + if ref_target in context.parameter_values: + param_value = context.parameter_values[ref_target] + # Handle CommaDelimitedList + if isinstance(param_value, str): + return [item.strip() for item in param_value.split(",")] + return param_value + + # Check parsed_template parameters + if context.parsed_template is not None and ref_target in context.parsed_template.parameters: + param_def = context.parsed_template.parameters[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + default_value = param_def["Default"] + param_type = param_def.get("Type", "String") + # Handle CommaDelimitedList default values + if param_type == "CommaDelimitedList" and isinstance(default_value, str): + return [item.strip() for item in default_value.split(",")] + return default_value + + # Check fragment Parameters + if "Parameters" in context.fragment: + params = context.fragment.get("Parameters", {}) + if isinstance(params, dict) and ref_target in params: + param_def = params[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + default_value = param_def["Default"] + param_type = param_def.get("Type", "String") + # Handle CommaDelimitedList default values + if param_type == "CommaDelimitedList" and isinstance(default_value, str): + return [item.strip() for item in default_value.split(",")] + return default_value + + # Return collection as-is if we can't resolve it + return collection + + def _validate_collection_resolvability(self, collection: Any) -> None: + """ + Validate that a collection can be resolved locally. + + Cloud-dependent intrinsics (Fn::GetAtt, Fn::ImportValue) and dynamic + references (SSM/Secrets Manager) cannot be resolved locally and will + raise an error with a helpful workaround message. + + Args: + collection: The collection value to validate. + + Raises: + InvalidTemplateException: If the collection contains cloud-dependent + intrinsics that cannot be resolved locally. + + Requirements: + - 5.1: Raise error for Fn::GetAtt in collection + - 5.2: Raise error for Fn::ImportValue in collection + - 5.3: Raise error for SSM/Secrets Manager dynamic references + - 5.4: Error message explains collection cannot be resolved locally + - 5.5: Error message suggests parameter workaround + """ + if isinstance(collection, dict) and len(collection) == 1: + key = next(iter(collection.keys())) + + # Check for Fn::GetAtt (Requirement 5.1) + if key in ("Fn::GetAtt", "!GetAtt"): + raise InvalidTemplateException(self._build_cloud_dependent_error_message("Fn::GetAtt", collection[key])) + + # Check for Fn::ImportValue (Requirement 5.2) + if key in ("Fn::ImportValue", "!ImportValue"): + raise InvalidTemplateException( + self._build_cloud_dependent_error_message("Fn::ImportValue", collection[key]) + ) + + # Check for SSM/Secrets Manager dynamic references (Requirement 5.3) + if isinstance(collection, str): + self._check_dynamic_reference(collection) + + def _validate_collection_items(self, collection: list) -> None: + """ + Validate that collection items do not contain cloud-dependent values. + + Args: + collection: The collection list to validate. + + Raises: + InvalidTemplateException: If any item contains cloud-dependent values. + """ + for item in collection: + if isinstance(item, dict): + self._validate_collection_resolvability(item) + elif isinstance(item, str): + self._check_dynamic_reference(item) + elif isinstance(item, list): + self._validate_collection_items(item) + + def _check_dynamic_reference(self, value: str) -> None: + """ + Check if a string value contains SSM/Secrets Manager dynamic references. + + Dynamic references have the format {{resolve:service:reference}} + where service can be ssm, ssm-secure, or secretsmanager. + + Args: + value: The string value to check. + + Raises: + InvalidTemplateException: If the value contains a dynamic reference. + + Requirements: + - 5.3: Raise error for SSM/Secrets Manager dynamic references + """ + import re + + # Pattern for dynamic references: {{resolve:service:reference}} + dynamic_ref_pattern = r"\{\{resolve:(ssm|ssm-secure|secretsmanager):[^}]+\}\}" + match = re.search(dynamic_ref_pattern, value, re.IGNORECASE) + + if match: + service = match.group(1) + raise InvalidTemplateException(self._build_dynamic_reference_error_message(service, value)) + + def _build_cloud_dependent_error_message(self, intrinsic_name: str, target: Any) -> str: + """ + Build an error message for cloud-dependent intrinsics in collections. + + Args: + intrinsic_name: The name of the intrinsic function (e.g., "Fn::GetAtt"). + target: The target of the intrinsic function. + + Returns: + A formatted error message with workaround suggestion. + + Requirements: + - 5.4: Error message explains collection cannot be resolved locally + - 5.5: Error message suggests parameter workaround + """ + target_str = str(target) if not isinstance(target, str) else target + return ( + f"Unable to resolve Fn::ForEach collection locally. " + f"The collection uses '{intrinsic_name}' which requires deployed resources " + f"and cannot be resolved locally.\n\n" + f"Target: {target_str}\n\n" + f"Workaround: Use a parameter instead:\n" + f" Parameters:\n" + f" CollectionValues:\n" + f" Type: CommaDelimitedList\n\n" + f" Fn::ForEach::MyLoop:\n" + f" - Item\n" + f" - !Ref CollectionValues\n" + f" - ...\n\n" + f'Then provide the value: sam build --parameter-overrides CollectionValues="Value1,Value2,Value3"' + ) + + def _build_dynamic_reference_error_message(self, service: str, value: str) -> str: + """ + Build an error message for dynamic references in collections. + + Args: + service: The dynamic reference service (ssm, ssm-secure, secretsmanager). + value: The full dynamic reference string. + + Returns: + A formatted error message with workaround suggestion. + + Requirements: + - 5.4: Error message explains collection cannot be resolved locally + - 5.5: Error message suggests parameter workaround + """ + service_name = { + "ssm": "AWS Systems Manager Parameter Store", + "ssm-secure": "AWS Systems Manager Parameter Store (SecureString)", + "secretsmanager": "AWS Secrets Manager", + }.get(service.lower(), service) + + return ( + f"Unable to resolve Fn::ForEach collection locally. " + f"The collection uses a dynamic reference to {service_name} which requires AWS API calls " + f"and cannot be resolved locally.\n\n" + f"Value: {value}\n\n" + f"Workaround: Use a parameter instead:\n" + f" Parameters:\n" + f" CollectionValues:\n" + f" Type: CommaDelimitedList\n\n" + f" Fn::ForEach::MyLoop:\n" + f" - Item\n" + f" - !Ref CollectionValues\n" + f" - ...\n\n" + f'Then provide the value: sam build --parameter-overrides CollectionValues="Value1,Value2,Value3"' + ) + + def get_foreach_loop_name(self, key: str) -> str: + """ + Extract the loop name from a Fn::ForEach key. + + For a key like "Fn::ForEach::Topics", this returns "Topics". + + Args: + key: The ForEach key. + + Returns: + The loop name portion of the key. + """ + if not self._is_foreach_key(key): + raise ValueError(f"Key '{key}' is not a valid Fn::ForEach key") + return key[len(self.FOREACH_PREFIX) :] diff --git a/samcli/lib/cfn_language_extensions/processors/parsing.py b/samcli/lib/cfn_language_extensions/processors/parsing.py new file mode 100644 index 00000000000..02cd94ec47f --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/parsing.py @@ -0,0 +1,129 @@ +""" +Template parsing processor for CloudFormation Language Extensions. + +This module provides the TemplateParsingProcessor which parses raw template +dictionaries into structured ParsedTemplate objects and validates the +template structure. +""" + +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import ParsedTemplate, TemplateProcessingContext + + +class TemplateParsingProcessor: + """ + Parses and validates template structure. + + This processor is responsible for: + - Converting raw template dictionaries into ParsedTemplate objects + - Initializing missing optional sections as empty dictionaries + - Validating that the Resources section is not null + - Validating that no resources or outputs are explicitly set to null + + Requirements: + - 2.1: Parse valid JSON/YAML template into structured Template object + - 2.2: Raise InvalidTemplateException when Resources section is null/missing + - 2.3: Raise InvalidTemplateException when an Output is explicitly null + - 2.4: Raise InvalidTemplateException when a Resource is explicitly null + - 2.5: Raise InvalidTemplateException for invalid JSON/YAML syntax + - 2.6: Initialize missing optional sections as empty dictionaries + + Example: + >>> processor = TemplateParsingProcessor() + >>> context = TemplateProcessingContext( + ... fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + ... ) + >>> processor.process_template(context) + >>> print(context.parsed_template.resources) + {'MyBucket': {'Type': 'AWS::S3::Bucket'}} + """ + + def process_template(self, context: TemplateProcessingContext) -> None: + """ + Process the template by parsing and validating it. + + This method parses the template fragment into a ParsedTemplate + object and validates its structure. The parsed template is + stored in context.parsed_template. + + Args: + context: The mutable template processing context containing + the template fragment to parse. + + Raises: + InvalidTemplateException: If the template structure is invalid. + """ + parsed = self._parse_template(context.fragment) + self._validate_template(parsed) + context.parsed_template = parsed + + def _parse_template(self, fragment: Dict[str, Any]) -> ParsedTemplate: + """ + Convert raw dict to ParsedTemplate. + + This method extracts all standard CloudFormation template sections + from the raw dictionary and creates a ParsedTemplate object. + Missing optional sections (Parameters, Conditions, Outputs, Mappings) + are initialized as empty dictionaries per Requirement 2.6. + + Note: Resources is NOT optional and is NOT converted to empty dict. + The validation step will check if Resources is null/missing. + + Args: + fragment: The raw template dictionary. + + Returns: + A ParsedTemplate object with all sections populated. + """ + # Get resources without converting None to {} - validation will check this + resources = fragment.get("Resources") + + return ParsedTemplate( + aws_template_format_version=fragment.get("AWSTemplateFormatVersion"), + description=fragment.get("Description"), + # Optional sections: initialize as empty dict if missing/null (Req 2.6) + parameters=fragment.get("Parameters") or {}, + mappings=fragment.get("Mappings") or {}, + conditions=fragment.get("Conditions") or {}, + # Resources is required - keep as-is for validation + resources=resources, + # Outputs: initialize as empty dict if missing, but preserve None values + # inside outputs for validation + outputs=fragment.get("Outputs") or {}, + transform=fragment.get("Transform"), + ) + + def _validate_template(self, template: ParsedTemplate) -> None: + """ + Validate template structure. + + This method performs structural validation on the parsed template: + - Ensures the Resources section is not null or missing (Requirement 2.2) + - Ensures no resource definitions are null (Requirement 2.4) + - Ensures no output definitions are null (Requirement 2.3) + + Args: + template: The parsed template to validate. + + Raises: + InvalidTemplateException: If validation fails. + """ + # Validate Resources section is not null or missing (Requirement 2.2) + if template.resources is None: + raise InvalidTemplateException("The Resources section must not be null") + + # Validate no null resource definitions (Requirement 2.4) + for logical_id, resource in template.resources.items(): + if resource is None: + raise InvalidTemplateException(f"[/Resources/{logical_id}] resource definition is malformed") + # Validate resource is a dictionary or a list (for Fn::ForEach) + # Fn::ForEach keys have format "Fn::ForEach::LoopName" and values are lists + if not isinstance(resource, (dict, list)): + raise InvalidTemplateException(f"[/Resources/{logical_id}] resource definition is malformed") + + # Validate no null output definitions (Requirement 2.3) + for logical_id, output in template.outputs.items(): + if output is None: + raise InvalidTemplateException(f"[/Outputs/{logical_id}] 'null' values are not allowed") diff --git a/samcli/lib/cfn_language_extensions/processors/resource_policy.py b/samcli/lib/cfn_language_extensions/processors/resource_policy.py new file mode 100644 index 00000000000..4f1af9190f0 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/resource_policy.py @@ -0,0 +1,154 @@ +""" +Base resource policy processor for CloudFormation Language Extensions. + +This module provides the ResourcePolicyProcessor base class that handles +validation and resolution of resource policy attributes (DeletionPolicy, +UpdateReplacePolicy) on CloudFormation resources. + +Both DeletionPolicyProcessor and UpdateReplacePolicyProcessor inherit from +this base class, eliminating code duplication. + +Requirements: + - 7.1-7.2: Resolve parameter references in policy values + - 7.3: Substitute parameter values for Ref in policies + - 7.4: Raise exception for AWS::NoValue references + - 7.5: Raise exception for non-string resolved values +""" + +from typing import Any, Dict, Optional + +from samcli.lib.cfn_language_extensions.exceptions import ( + InvalidTemplateException, + PublicFacingErrorMessages, +) +from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + + +class ResourcePolicyProcessor: + """ + Base class for validating and resolving resource policy attributes. + + This processor handles DeletionPolicy and UpdateReplacePolicy attributes + on CloudFormation resources, resolving parameter references and validating + that the final value is a valid string. It rejects AWS::NoValue references + as they are not supported for resource policies. + + Subclasses only need to set POLICY_NAME to the appropriate attribute name. + + Attributes: + POLICY_NAME: The name of the policy attribute this processor handles. + UNSUPPORTED_PSEUDO_PARAMS: Set of pseudo-parameters not supported for policies. + """ + + POLICY_NAME: str = "" + UNSUPPORTED_PSEUDO_PARAMS = {"AWS::NoValue"} + + def process_template(self, context: TemplateProcessingContext) -> None: + """ + Process the template by validating and resolving policy attributes. + + Iterates through all resources and processes any policy attributes found. + + Args: + context: The mutable template processing context. + + Raises: + InvalidTemplateException: If a policy value is invalid. + """ + resources = context.fragment.get("Resources", {}) + + if not isinstance(resources, dict): + return + + for logical_id, resource in resources.items(): + if not isinstance(resource, dict): + continue + + policy = resource.get(self.POLICY_NAME) + if policy is not None: + resolved_policy = self._resolve_and_validate_policy(logical_id, policy, context) + resource[self.POLICY_NAME] = resolved_policy + + def _resolve_and_validate_policy(self, logical_id: str, policy: Any, context: TemplateProcessingContext) -> str: + """ + Resolve and validate a policy value. + + Args: + logical_id: The logical ID of the resource. + policy: The policy value to resolve and validate. + context: The template processing context. + + Returns: + The resolved policy value as a string. + + Raises: + InvalidTemplateException: If the policy is invalid. + """ + if isinstance(policy, str): + return policy + + if isinstance(policy, list): + raise InvalidTemplateException(PublicFacingErrorMessages.invalid_policy_string(self.POLICY_NAME)) + + if isinstance(policy, dict): + return self._resolve_intrinsic_policy(logical_id, policy, context) + + raise InvalidTemplateException(PublicFacingErrorMessages.unresolved_policy(self.POLICY_NAME, logical_id)) + + def _resolve_intrinsic_policy( + self, logical_id: str, policy: Dict[str, Any], context: TemplateProcessingContext + ) -> str: + """ + Resolve an intrinsic function in a policy value. + + Args: + logical_id: The logical ID of the resource. + policy: The policy dict containing an intrinsic function. + context: The template processing context. + + Returns: + The resolved policy value as a string. + + Raises: + InvalidTemplateException: If the policy is invalid. + """ + if "Ref" in policy: + ref_target = policy["Ref"] + + if ref_target in self.UNSUPPORTED_PSEUDO_PARAMS: + raise InvalidTemplateException(PublicFacingErrorMessages.not_supported_for_policies(ref_target)) + + resolved_value = self._resolve_parameter_ref(ref_target, context) + + if resolved_value is not None: + if not isinstance(resolved_value, str): + raise InvalidTemplateException( + PublicFacingErrorMessages.unresolved_policy(self.POLICY_NAME, logical_id) + ) + return resolved_value + + raise InvalidTemplateException(PublicFacingErrorMessages.unresolved_policy(self.POLICY_NAME, logical_id)) + + raise InvalidTemplateException(PublicFacingErrorMessages.unresolved_policy(self.POLICY_NAME, logical_id)) + + def _resolve_parameter_ref(self, ref_target: str, context: TemplateProcessingContext) -> Optional[Any]: + """ + Resolve a Ref to a parameter. + + Args: + ref_target: The reference target string. + context: The template processing context. + + Returns: + The parameter value if found, None otherwise. + """ + if ref_target in context.parameter_values: + return context.parameter_values[ref_target] + + if context.parsed_template is not None: + if ref_target in context.parsed_template.parameters: + param_def = context.parsed_template.parameters[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + return param_def["Default"] + + return None diff --git a/samcli/lib/cfn_language_extensions/processors/update_replace_policy.py b/samcli/lib/cfn_language_extensions/processors/update_replace_policy.py new file mode 100644 index 00000000000..78b15b41c33 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/processors/update_replace_policy.py @@ -0,0 +1,45 @@ +""" +UpdateReplacePolicy processor for CloudFormation Language Extensions. + +This module provides the UpdateReplacePolicyProcessor which validates and resolves +UpdateReplacePolicy attributes on CloudFormation resources. + +Requirements: + - 7.2: WHEN a resource has an UpdateReplacePolicy attribute, THEN THE Processor + SHALL resolve any parameter references in the policy value + - 7.3: WHEN UpdateReplacePolicy contains a Ref to a parameter, THEN THE Processor + SHALL substitute the parameter's value + - 7.4: WHEN UpdateReplacePolicy resolves to AWS::NoValue, THEN THE Processor + SHALL raise an Invalid_Template_Exception + - 7.5: WHEN UpdateReplacePolicy does not resolve to a valid string value, THEN + THE Processor SHALL raise an Invalid_Template_Exception +""" + +from samcli.lib.cfn_language_extensions.processors.resource_policy import ResourcePolicyProcessor + + +class UpdateReplacePolicyProcessor(ResourcePolicyProcessor): + """ + Validates and resolves UpdateReplacePolicy attributes on resources. + + Valid UpdateReplacePolicy values are: "Delete", "Retain", "Snapshot" + + Example: + >>> processor = UpdateReplacePolicyProcessor() + >>> context = TemplateProcessingContext( + ... fragment={ + ... "Resources": { + ... "MyBucket": { + ... "Type": "AWS::S3::Bucket", + ... "UpdateReplacePolicy": {"Ref": "PolicyParam"} + ... } + ... } + ... }, + ... parameter_values={"PolicyParam": "Retain"} + ... ) + >>> processor.process_template(context) + >>> print(context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"]) + "Retain" + """ + + POLICY_NAME = "UpdateReplacePolicy" diff --git a/samcli/lib/cfn_language_extensions/resolvers/__init__.py b/samcli/lib/cfn_language_extensions/resolvers/__init__.py new file mode 100644 index 00000000000..fcf50a3e8f9 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/__init__.py @@ -0,0 +1,43 @@ +""" +Intrinsic function resolvers for CloudFormation Language Extensions. + +This module provides the resolver infrastructure for processing CloudFormation +intrinsic functions. It includes the base class for all resolvers and constants +defining which intrinsic functions can be resolved locally vs. must be preserved. +""" + +from samcli.lib.cfn_language_extensions.resolvers.base import ( + RESOLVABLE_INTRINSICS, + UNRESOLVABLE_INTRINSICS, + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.condition_resolver import ConditionResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_base64 import FnBase64Resolver +from samcli.lib.cfn_language_extensions.resolvers.fn_find_in_map import FnFindInMapResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_if import FnIfResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_join import FnJoinResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_length import FnLengthResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_select import FnSelectResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_split import FnSplitResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_sub import FnSubResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_to_json_string import FnToJsonStringResolver + +__all__ = [ + "IntrinsicFunctionResolver", + "IntrinsicResolver", + "FnLengthResolver", + "FnToJsonStringResolver", + "FnFindInMapResolver", + "FnRefResolver", + "FnSubResolver", + "FnJoinResolver", + "FnSplitResolver", + "FnSelectResolver", + "FnBase64Resolver", + "ConditionResolver", + "FnIfResolver", + "RESOLVABLE_INTRINSICS", + "UNRESOLVABLE_INTRINSICS", +] diff --git a/samcli/lib/cfn_language_extensions/resolvers/base.py b/samcli/lib/cfn_language_extensions/resolvers/base.py new file mode 100644 index 00000000000..7eec1b65fdb --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/base.py @@ -0,0 +1,601 @@ +""" +Base class and infrastructure for CloudFormation intrinsic function resolvers. + +This module provides the foundational classes and constants for resolving +CloudFormation intrinsic functions during template processing. + +The resolver pattern uses: +- IntrinsicFunctionResolver: Base class for individual function resolvers +- Resolver chain: Composable pattern for handling multiple intrinsic functions +- Constants: Define which functions can be resolved locally vs. preserved +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + + +# Intrinsic functions that can be resolved locally during template processing. +# These functions can be evaluated without access to deployed CloudFormation resources. +RESOLVABLE_INTRINSICS = { + "Fn::Length", # Returns count of list elements + "Fn::ToJsonString", # Converts value to JSON string + "Fn::FindInMap", # Looks up values in Mappings section + "Fn::If", # Conditional value selection + "Fn::Sub", # String substitution with variables + "Fn::Join", # Concatenates strings with delimiter + "Fn::Split", # Splits string into list + "Fn::Select", # Selects item from list by index + "Fn::Base64", # Base64 encodes a string + "Fn::Equals", # Compares two values for equality + "Fn::And", # Logical AND of conditions + "Fn::Or", # Logical OR of conditions + "Fn::Not", # Logical NOT of condition + "Ref", # Only for parameters and pseudo-parameters +} + +# Intrinsic functions that must be preserved for CloudFormation to resolve. +# These functions require access to deployed resources or runtime information. +UNRESOLVABLE_INTRINSICS = { + "Fn::GetAtt", # Gets attribute from a resource (requires deployed resource) + "Fn::ImportValue", # Imports value from another stack's exports + "Fn::GetAZs", # Gets availability zones (runtime information) + "Fn::Cidr", # Generates CIDR blocks (complex calculation) + "Ref", # When referencing resources (not parameters) +} + + +class IntrinsicFunctionResolver(ABC): + """ + Base class for resolving CloudFormation intrinsic functions. + + This abstract base class defines the interface for intrinsic function + resolvers. Each resolver handles one or more specific intrinsic functions + (e.g., Fn::Length, Fn::Sub) and knows how to evaluate them. + + The resolver pattern supports: + - Pattern matching via `can_resolve()` to determine if a value is handled + - Resolution via `resolve()` to evaluate the intrinsic function + - Composition via parent resolver for nested intrinsic resolution + + Subclasses must: + - Set FUNCTION_NAMES class attribute with the intrinsic function names handled + - Implement the `resolve()` method to evaluate the function + + Attributes: + FUNCTION_NAMES: List of intrinsic function names this resolver handles. + E.g., ["Fn::Length"] or ["Fn::Join", "Fn::Split"] + context: The template processing context with parameters, mappings, etc. + parent: The parent IntrinsicResolver for resolving nested intrinsics. + + Example: + >>> class FnLengthResolver(IntrinsicFunctionResolver): + ... FUNCTION_NAMES = ["Fn::Length"] + ... + ... def resolve(self, value: Dict[str, Any]) -> int: + ... args = value["Fn::Length"] + ... resolved_args = self.parent.resolve_value(args) + ... if not isinstance(resolved_args, list): + ... raise InvalidTemplateException("Fn::Length layout is incorrect") + ... return len(resolved_args) + + Requirements: + - 10.1: Support Fn::Ref for parameter and resource references + - 10.2: Support Fn::Sub for string substitution with variables + - 10.3: Support Fn::Join for concatenating strings with a delimiter + - 10.4: Support Fn::Split for splitting strings into lists + - 10.5: Support Fn::Select for selecting items from lists by index + - 10.6: Support Fn::If for conditional value selection + - 10.7: Support Fn::GetAtt by preserving it unresolved + - 10.8: Support Fn::Base64 for base64 encoding strings + """ + + # Intrinsic function names this resolver handles. + # Subclasses must override this with their specific function names. + FUNCTION_NAMES: List[str] = [] + + def __init__( + self, context: "TemplateProcessingContext", parent_resolver: Optional["IntrinsicResolver"] = None + ) -> None: + """ + Initialize the resolver with context and parent resolver. + + Args: + context: The template processing context containing parameters, + mappings, conditions, and other template state. + parent_resolver: The parent IntrinsicResolver used for resolving + nested intrinsic functions. Can be None for + testing or standalone use. + + Example: + >>> context = TemplateProcessingContext(fragment=template) + >>> resolver = FnLengthResolver(context, parent_resolver) + """ + self.context = context + self.parent = parent_resolver + + def can_resolve(self, value: Any) -> bool: + """ + Check if this resolver can handle the given value. + + This method implements pattern matching for CloudFormation intrinsic + functions. An intrinsic function is represented as a dictionary with + exactly one key that matches one of the function names this resolver + handles. + + Args: + value: The value to check. Can be any type. + + Returns: + True if this resolver can handle the value (i.e., it's a dict + with exactly one key matching a function name in FUNCTION_NAMES). + False otherwise. + + Example: + >>> resolver = FnLengthResolver(context, parent) + >>> resolver.can_resolve({"Fn::Length": [1, 2, 3]}) + True + >>> resolver.can_resolve({"Fn::Sub": "hello"}) + False + >>> resolver.can_resolve("not a dict") + False + >>> resolver.can_resolve({"key1": "v1", "key2": "v2"}) + False + """ + # Must be a dictionary + if not isinstance(value, dict): + return False + + # Must have exactly one key (intrinsic function pattern) + if len(value) != 1: + return False + + # The key must be one of the function names this resolver handles + key = next(iter(value.keys())) + return key in self.FUNCTION_NAMES + + @abstractmethod + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the intrinsic function and return the result. + + This method must be implemented by subclasses to evaluate the + specific intrinsic function. The value is guaranteed to be a + dictionary with exactly one key matching one of FUNCTION_NAMES + (as verified by can_resolve()). + + Implementations should: + - Extract the function arguments from the value + - Resolve any nested intrinsic functions using self.parent.resolve_value() + - Perform the function-specific logic + - Return the resolved value + - Raise InvalidTemplateException for invalid inputs + + Args: + value: A dictionary representing the intrinsic function. + E.g., {"Fn::Length": [1, 2, 3]} + + Returns: + The resolved value. The type depends on the specific function. + + Raises: + InvalidTemplateException: If the function arguments are invalid + or resolution fails. + + Example: + >>> # For Fn::Length resolver + >>> resolver.resolve({"Fn::Length": [1, 2, 3]}) + 3 + """ + raise NotImplementedError(f"Subclass must implement resolve() for {self.FUNCTION_NAMES}") + + def get_function_name(self, value: Dict[str, Any]) -> str: + """ + Extract the function name from an intrinsic function value. + + This is a utility method for subclasses that handle multiple + function names and need to determine which specific function + is being resolved. + + Args: + value: A dictionary representing the intrinsic function. + + Returns: + The function name (the single key in the dictionary). + + Example: + >>> resolver.get_function_name({"Fn::Length": [1, 2, 3]}) + "Fn::Length" + """ + return next(iter(value.keys())) + + def get_function_args(self, value: Dict[str, Any]) -> Any: + """ + Extract the function arguments from an intrinsic function value. + + This is a utility method for subclasses to easily access the + arguments of the intrinsic function. + + Args: + value: A dictionary representing the intrinsic function. + + Returns: + The function arguments (the single value in the dictionary). + + Example: + >>> resolver.get_function_args({"Fn::Length": [1, 2, 3]}) + [1, 2, 3] + """ + return next(iter(value.values())) + + +class IntrinsicResolver: + """ + Orchestrator for resolving intrinsic functions in CloudFormation templates. + + This class manages a chain of IntrinsicFunctionResolver instances and + coordinates the resolution of intrinsic functions throughout a template. + It provides the `resolve_value()` method that recursively walks through + template structures and resolves intrinsic functions. + + The resolver chain pattern allows: + - Composing multiple resolvers for different intrinsic functions + - Recursive resolution of nested intrinsic functions + - Partial resolution mode (preserving unresolvable references) + + Partial Resolution Mode: + When context.resolution_mode is ResolutionMode.PARTIAL, the resolver + preserves intrinsic functions that cannot be resolved locally: + - Fn::GetAtt: Requires deployed resource attributes + - Fn::ImportValue: Requires cross-stack exports + - Ref to resources: Requires deployed resource physical IDs + + Language extension functions are still resolved in partial mode: + - Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue + - Fn::If conditions where the condition value is known + + Attributes: + context: The template processing context. + resolvers: List of IntrinsicFunctionResolver instances in the chain. + preserve_functions: Set of function names to preserve in partial mode. + + Example: + >>> context = TemplateProcessingContext( + ... fragment=template, + ... resolution_mode=ResolutionMode.PARTIAL + ... ) + >>> orchestrator = IntrinsicResolver(context) + >>> orchestrator.register_resolver(FnLengthResolver) + >>> orchestrator.register_resolver(FnSubResolver) + >>> result = orchestrator.resolve_value({"Fn::Length": [1, 2, 3]}) + 3 + + Requirements: + - 16.1: Support partial resolution mode that preserves Fn::Ref to resources + - 16.2: Support partial resolution mode that preserves Fn::GetAtt references + - 16.3: Support partial resolution mode that preserves Fn::ImportValue references + - 16.4: In partial mode, still resolve Fn::ForEach, Fn::Length, Fn::ToJsonString, + and Fn::FindInMap with DefaultValue + - 16.5: In partial mode, resolve Fn::If conditions where condition value is known + - 16.6: Allow configuration of which intrinsic functions to preserve vs resolve + """ + + # Default functions to preserve in partial resolution mode. + # These functions require deployed resources or cross-stack information. + DEFAULT_PRESERVE_FUNCTIONS = { + "Fn::GetAtt", # Requires deployed resource attributes + "Fn::ImportValue", # Requires cross-stack exports + "Fn::GetAZs", # Requires runtime AWS information + "Fn::Cidr", # Complex calculation, often preserved + } + + def __init__(self, context: "TemplateProcessingContext", preserve_functions: Optional[set] = None) -> None: + """ + Initialize the orchestrator with a template processing context. + + Args: + context: The template processing context containing parameters, + mappings, conditions, and other template state. + preserve_functions: Optional set of function names to preserve + in partial resolution mode. If None, uses + DEFAULT_PRESERVE_FUNCTIONS. This allows + configuration of which functions to preserve. + + Example: + >>> context = TemplateProcessingContext(fragment=template) + >>> # Use default preserve functions + >>> orchestrator = IntrinsicResolver(context) + >>> # Or customize which functions to preserve + >>> orchestrator = IntrinsicResolver( + ... context, + ... preserve_functions={"Fn::GetAtt", "Fn::ImportValue"} + ... ) + """ + self.context = context + self._resolvers: List[IntrinsicFunctionResolver] = [] + self._preserve_functions = ( + preserve_functions if preserve_functions is not None else self.DEFAULT_PRESERVE_FUNCTIONS.copy() + ) + + @property + def preserve_functions(self) -> set: + """ + Get the set of function names to preserve in partial mode. + + Returns: + A copy of the preserve functions set. + """ + return self._preserve_functions.copy() + + def set_preserve_functions(self, functions: set) -> "IntrinsicResolver": + """ + Set the functions to preserve in partial resolution mode. + + This method allows runtime configuration of which intrinsic + functions should be preserved rather than resolved. + + Args: + functions: Set of function names to preserve. + + Returns: + Self for method chaining. + + Example: + >>> orchestrator.set_preserve_functions({"Fn::GetAtt", "Fn::ImportValue"}) + """ + self._preserve_functions = functions.copy() + return self + + def add_preserve_function(self, function_name: str) -> "IntrinsicResolver": + """ + Add a function to the preserve list. + + Args: + function_name: The intrinsic function name to preserve. + + Returns: + Self for method chaining. + """ + self._preserve_functions.add(function_name) + return self + + def remove_preserve_function(self, function_name: str) -> "IntrinsicResolver": + """ + Remove a function from the preserve list. + + Args: + function_name: The intrinsic function name to remove. + + Returns: + Self for method chaining. + """ + self._preserve_functions.discard(function_name) + return self + + def register_resolver( + self, + resolver_class: type, + ) -> "IntrinsicResolver": + """ + Register a resolver class with this orchestrator. + + Creates an instance of the resolver class and adds it to the + resolver chain. Returns self for method chaining. + + Args: + resolver_class: A subclass of IntrinsicFunctionResolver. + + Returns: + Self for method chaining. + + Example: + >>> orchestrator.register_resolver(FnLengthResolver) + ... .register_resolver(FnSubResolver) + """ + resolver = resolver_class(self.context, self) + self._resolvers.append(resolver) + return self + + def add_resolver(self, resolver: IntrinsicFunctionResolver) -> "IntrinsicResolver": + """ + Add an already-instantiated resolver to the chain. + + This method allows adding pre-configured resolver instances + rather than having the orchestrator create them. + + Args: + resolver: An IntrinsicFunctionResolver instance. + + Returns: + Self for method chaining. + """ + self._resolvers.append(resolver) + return self + + def _is_intrinsic_function(self, value: Any) -> bool: + """ + Check if a value represents an intrinsic function. + + An intrinsic function is a dict with exactly one key that starts + with "Fn::" or is "Ref" or "Condition". + + Args: + value: The value to check. + + Returns: + True if the value is an intrinsic function pattern. + """ + if not isinstance(value, dict) or len(value) != 1: + return False + key = next(iter(value.keys())) + return key.startswith("Fn::") or key in ("Ref", "Condition") + + def _get_intrinsic_name(self, value: Dict[str, Any]) -> str: + """ + Get the intrinsic function name from a value. + + Args: + value: A dict representing an intrinsic function. + + Returns: + The function name (the single key). + """ + return next(iter(value.keys())) + + def _should_preserve(self, value: Dict[str, Any]) -> bool: + """ + Determine if an intrinsic function should be preserved. + + In partial resolution mode, certain intrinsic functions are + preserved rather than resolved because they require information + that is only available at deployment time. + + Args: + value: A dict representing an intrinsic function. + + Returns: + True if the function should be preserved, False otherwise. + + Requirements: + - 16.1: Preserve Fn::Ref to resources in partial mode + - 16.2: Preserve Fn::GetAtt references in partial mode + - 16.3: Preserve Fn::ImportValue references in partial mode + """ + # Import here to avoid circular imports + from samcli.lib.cfn_language_extensions.models import ResolutionMode + + # Only preserve in partial resolution mode + if self.context.resolution_mode != ResolutionMode.PARTIAL: + return False + + fn_name = self._get_intrinsic_name(value) + + # Check if this function is in the preserve list + if fn_name in self._preserve_functions: + return True + + # Special handling for Ref - only preserve references to resources + if fn_name == "Ref": + return self._is_resource_ref(value) + + return False + + def _is_resource_ref(self, value: Dict[str, Any]) -> bool: + """ + Check if a Ref intrinsic references a resource (vs parameter/pseudo-param). + + Refs to parameters and pseudo-parameters can be resolved locally, + but Refs to resources must be preserved for CloudFormation. + + Args: + value: A dict representing a Ref intrinsic function. + + Returns: + True if the Ref is to a resource, False if to a parameter + or pseudo-parameter. + """ + ref_target = value.get("Ref") + if not isinstance(ref_target, str): + return False + + # Check if it's a pseudo-parameter + pseudo_params = { + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", + } + if ref_target in pseudo_params: + return False + + # Check if it's a template parameter + if self.context.parsed_template is not None: + if ref_target in self.context.parsed_template.parameters: + return False + + # Check parameter_values as fallback + if ref_target in self.context.parameter_values: + return False + + # If not a pseudo-param or template param, assume it's a resource ref + return True + + def resolve_value(self, value: Any) -> Any: + """ + Resolve intrinsic functions in a value recursively. + + This method walks through the value structure (dicts, lists, primitives) + and resolves any intrinsic functions found. Nested intrinsic functions + are resolved recursively. + + In partial resolution mode (context.resolution_mode == ResolutionMode.PARTIAL), + unresolvable intrinsic functions are preserved in the output: + - Fn::GetAtt: Preserved (requires deployed resource attributes) + - Fn::ImportValue: Preserved (requires cross-stack exports) + - Ref to resources: Preserved (requires deployed resource physical IDs) + + Language extension functions are always resolved: + - Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue + + Args: + value: The value to resolve. Can be any type. + + Returns: + The resolved value with intrinsic functions evaluated, + or preserved intrinsic functions in partial mode. + + Requirements: + - 16.1: Preserve Fn::Ref to resources in partial mode + - 16.2: Preserve Fn::GetAtt references in partial mode + - 16.3: Preserve Fn::ImportValue references in partial mode + - 16.4: Resolve Fn::ForEach, Fn::Length, Fn::ToJsonString, + Fn::FindInMap with DefaultValue in partial mode + - 16.5: Resolve Fn::If conditions where condition value is known + + Example: + >>> # In partial mode, Fn::GetAtt is preserved + >>> orchestrator.resolve_value({"Fn::GetAtt": ["MyBucket", "Arn"]}) + {"Fn::GetAtt": ["MyBucket", "Arn"]} + + >>> # But Fn::Length is still resolved + >>> orchestrator.resolve_value({"Fn::Length": [1, 2, 3]}) + 3 + """ + # Check if this is an intrinsic function that should be preserved + if self._is_intrinsic_function(value) and self._should_preserve(value): + # Still recursively resolve the arguments in case they contain + # resolvable intrinsics + fn_name = self._get_intrinsic_name(value) + fn_args = value[fn_name] + resolved_args = self.resolve_value(fn_args) + return {fn_name: resolved_args} + + # Check if any resolver can handle this value + for resolver in self._resolvers: + if resolver.can_resolve(value): + return resolver.resolve(value) + + # Recursively process dicts and lists + if isinstance(value, dict): + return {k: self.resolve_value(v) for k, v in value.items()} + elif isinstance(value, list): + return [self.resolve_value(item) for item in value] + + # Return primitives as-is + return value + + @property + def resolvers(self) -> List[IntrinsicFunctionResolver]: + """ + Get the list of registered resolvers. + + Returns: + A copy of the resolver list. + """ + return list(self._resolvers) diff --git a/samcli/lib/cfn_language_extensions/resolvers/condition_resolver.py b/samcli/lib/cfn_language_extensions/resolvers/condition_resolver.py new file mode 100644 index 00000000000..15bb3d20672 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/condition_resolver.py @@ -0,0 +1,419 @@ +""" +Condition intrinsic function resolvers. + +This module provides resolvers for CloudFormation condition intrinsic functions: +- Fn::Equals: Compares two values for equality +- Fn::And: Returns true if all conditions are true +- Fn::Or: Returns true if any condition is true +- Fn::Not: Returns the inverse of a condition +- Condition: References a named condition from the Conditions section + +Requirements: + - 8.1: WHEN a Condition section contains language extension functions, THEN THE + Resolver SHALL resolve them before evaluating conditions + - 8.4: WHEN conditions contain circular references, THEN THE Resolver SHALL + raise an Invalid_Template_Exception +""" + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + +if TYPE_CHECKING: + from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext + from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicResolver + + +class ConditionResolver(IntrinsicFunctionResolver): + """ + Resolves condition intrinsic functions. + + This resolver handles the CloudFormation condition functions: + - Fn::Equals: Compares two values for equality + - Fn::And: Returns true if all conditions are true (2-10 conditions) + - Fn::Or: Returns true if any condition is true (2-10 conditions) + - Fn::Not: Returns the inverse of a condition + - Condition: References a named condition from the Conditions section + + The resolver also detects circular condition references and raises + InvalidTemplateException when detected. + + Attributes: + FUNCTION_NAMES: List containing condition function names + _evaluating_conditions: Set of condition names currently being evaluated + (used for circular reference detection) + + Example: + >>> resolver = ConditionResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Equals": ["value1", "value1"]}) + True + >>> resolver.resolve({"Fn::And": [{"Condition": "Cond1"}, {"Condition": "Cond2"}]}) + # Returns True if both Cond1 and Cond2 are true + + Requirements: + - 8.1: Resolve language extension functions before evaluating conditions + - 8.4: Detect circular condition references + """ + + FUNCTION_NAMES = ["Fn::Equals", "Fn::And", "Fn::Or", "Fn::Not", "Condition"] + + def __init__( + self, context: "TemplateProcessingContext", parent_resolver: Optional["IntrinsicResolver"] = None + ) -> None: + """ + Initialize the resolver with context and parent resolver. + + Args: + context: The template processing context containing parameters, + mappings, conditions, and other template state. + parent_resolver: The parent IntrinsicResolver used for resolving + nested intrinsic functions. + """ + super().__init__(context, parent_resolver) + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the condition intrinsic function. + + This method dispatches to the appropriate handler based on the + function name. + + Args: + value: A dictionary representing the condition intrinsic function. + E.g., {"Fn::Equals": ["a", "a"]} or {"Condition": "MyCondition"} + + Returns: + A boolean value representing the condition result. + + Raises: + InvalidTemplateException: If the function layout is incorrect or + circular references are detected. + """ + fn_name = self.get_function_name(value) + args = self.get_function_args(value) + + if fn_name == "Fn::Equals": + return self._resolve_equals(args) + elif fn_name == "Fn::And": + return self._resolve_and(args) + elif fn_name == "Fn::Or": + return self._resolve_or(args) + elif fn_name == "Fn::Not": + return self._resolve_not(args) + elif fn_name == "Condition": + return self._resolve_condition_reference(args) + else: + raise InvalidTemplateException(f"{fn_name} layout is incorrect") + + def _resolve_equals(self, args: Any) -> bool: + """ + Resolve Fn::Equals intrinsic function. + + Fn::Equals compares two values and returns true if they are equal. + + Args: + args: A list of exactly two values to compare. + + Returns: + True if the values are equal, False otherwise. + + Raises: + InvalidTemplateException: If args is not a list of exactly 2 elements, + or if the values are not comparable types. + + Example: + >>> self._resolve_equals(["value1", "value1"]) + True + >>> self._resolve_equals(["value1", "value2"]) + False + """ + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Equals layout is incorrect") + + # Resolve nested intrinsics in both values + value1 = self._resolve_nested(args[0]) + value2 = self._resolve_nested(args[1]) + + # Validate that values are comparable types (not complex objects) + # Kotlin throws "Intrinsic function input type is invalid" for dicts + # that are not intrinsic functions + if self._is_invalid_equals_value(value1) or self._is_invalid_equals_value(value2): + raise InvalidTemplateException("Intrinsic function input type is invalid") + + return bool(value1 == value2) + + def _is_invalid_equals_value(self, value: Any) -> bool: + """ + Check if a value is invalid for Fn::Equals comparison. + + Invalid values are dicts that are not intrinsic functions. + Unresolved intrinsic functions (like {"Ref": "AWS::StackName"}) are valid + because they will be resolved later. + + Args: + value: The value to check. + + Returns: + True if the value is invalid, False otherwise. + """ + if not isinstance(value, dict): + return False + + # Check if it's an intrinsic function (single key starting with Fn:: or Ref or Condition) + if len(value) == 1: + key = next(iter(value.keys())) + if key.startswith("Fn::") or key == "Ref" or key == "Condition": + return False # Valid - it's an unresolved intrinsic + + # It's a dict but not an intrinsic function - invalid + return True + + def _resolve_and(self, args: Any) -> bool: + """ + Resolve Fn::And intrinsic function. + + Fn::And returns true if all conditions are true. It accepts 2-10 conditions. + + Args: + args: A list of 2-10 condition values. + + Returns: + True if all conditions are true, False otherwise. + + Raises: + InvalidTemplateException: If args is not a list of 2-10 elements, + or if any element is not a valid condition. + + Example: + >>> self._resolve_and([True, True]) + True + >>> self._resolve_and([True, False]) + False + """ + if not isinstance(args, list) or len(args) < 2 or len(args) > 10: + raise InvalidTemplateException("Fn::And layout is incorrect") + + # Evaluate all conditions + for condition in args: + # Validate that each element is a valid condition operation + self._validate_condition_element(condition) + resolved = self._resolve_nested(condition) + # Convert to boolean + if not self._to_boolean(resolved): + return False + + return True + + def _resolve_or(self, args: Any) -> bool: + """ + Resolve Fn::Or intrinsic function. + + Fn::Or returns true if any condition is true. It accepts 2-10 conditions. + + Args: + args: A list of 2-10 condition values. + + Returns: + True if any condition is true, False otherwise. + + Raises: + InvalidTemplateException: If args is not a list of 2-10 elements, + or if any element is not a valid condition. + + Example: + >>> self._resolve_or([False, True]) + True + >>> self._resolve_or([False, False]) + False + """ + if not isinstance(args, list) or len(args) < 2 or len(args) > 10: + raise InvalidTemplateException("Fn::Or layout is incorrect") + + # Evaluate all conditions + for condition in args: + # Validate that each element is a valid condition operation + self._validate_condition_element(condition) + resolved = self._resolve_nested(condition) + # Convert to boolean + if self._to_boolean(resolved): + return True + + return False + + def _resolve_not(self, args: Any) -> bool: + """ + Resolve Fn::Not intrinsic function. + + Fn::Not returns the inverse of a condition. + + Args: + args: A list containing exactly one condition value. + + Returns: + The inverse of the condition value. + + Raises: + InvalidTemplateException: If args is not a list of exactly 1 element, + or if the element is not a valid condition. + + Example: + >>> self._resolve_not([True]) + False + >>> self._resolve_not([False]) + True + """ + if not isinstance(args, list) or len(args) != 1: + raise InvalidTemplateException("Fn::Not layout is incorrect") + + # Validate that the element is a valid condition operation + self._validate_condition_element(args[0]) + resolved = self._resolve_nested(args[0]) + return not self._to_boolean(resolved) + + def _validate_condition_element(self, element: Any) -> None: + """ + Validate that an element is a valid condition operation. + + Valid condition elements are: + - Boolean values (True/False) + - String "true"/"false" (case-insensitive) + - Dicts with a single key that is a condition function + (Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition) + + Args: + element: The element to validate. + + Raises: + InvalidTemplateException: If the element is not a valid condition. + """ + # Boolean values are always valid + if isinstance(element, bool): + return + + # String "true"/"false" are valid + if isinstance(element, str): + if element.lower() in ("true", "false"): + return + + # Dicts must have a single key that is a condition function + if isinstance(element, dict) and len(element) == 1: + key = next(iter(element.keys())) + # Valid condition functions + valid_condition_functions = {"Fn::Equals", "Fn::And", "Fn::Or", "Fn::Not", "Condition"} + if key in valid_condition_functions: + return + # Ref is NOT valid inside condition functions like Fn::And/Fn::Or/Fn::Not + if key == "Ref": + raise InvalidTemplateException("Conditions can only be boolean operations") + + # Other types are invalid + raise InvalidTemplateException("Conditions can only be boolean operations") + + def _resolve_condition_reference(self, args: Any) -> bool: + """ + Resolve a Condition reference. + + The Condition intrinsic function references a named condition from + the Conditions section of the template. + + Args: + args: The name of the condition to reference (string). + + Returns: + The boolean value of the referenced condition. + + Raises: + InvalidTemplateException: If the condition name is invalid, + the condition doesn't exist, or + circular references are detected. + + Example: + >>> self._resolve_condition_reference("IsProduction") + True # If IsProduction condition evaluates to true + """ + if not isinstance(args, str): + raise InvalidTemplateException("Condition layout is incorrect") + + condition_name = args + + # Check for circular reference using context's tracking set + if condition_name in self.context._evaluating_conditions: + raise InvalidTemplateException(f"Circular condition reference detected: {condition_name}") + + # Check if condition is already resolved in context + if condition_name in self.context.resolved_conditions: + return self.context.resolved_conditions[condition_name] + + # Get the condition definition from parsed template + if self.context.parsed_template is None: + raise InvalidTemplateException(f"Condition '{condition_name}' not found") + + conditions = self.context.parsed_template.conditions + if condition_name not in conditions: + raise InvalidTemplateException(f"Condition '{condition_name}' not found") + + # Mark this condition as being evaluated (for circular reference detection) + self.context._evaluating_conditions.add(condition_name) + + try: + # Resolve the condition definition + condition_def = conditions[condition_name] + resolved = self._resolve_nested(condition_def) + result = self._to_boolean(resolved) + + # Cache the result + self.context.resolved_conditions[condition_name] = result + + return result + finally: + # Remove from evaluating set + self.context._evaluating_conditions.discard(condition_name) + + def _resolve_nested(self, value: Any) -> Any: + """ + Resolve nested intrinsic functions. + + This method uses the parent resolver to resolve any nested intrinsic + functions in the value. + + Args: + value: The value to resolve. + + Returns: + The resolved value. + """ + if self.parent is not None: + return self.parent.resolve_value(value) + return value + + def _to_boolean(self, value: Any) -> bool: + """ + Convert a value to a boolean. + + CloudFormation conditions can be: + - Boolean values (True/False) + - String "true"/"false" (case-insensitive) + + Args: + value: The value to convert. + + Returns: + The boolean representation of the value. + + Raises: + InvalidTemplateException: If the value cannot be converted to boolean. + """ + if isinstance(value, bool): + return value + + if isinstance(value, str): + lower_value = value.lower() + if lower_value == "true": + return True + elif lower_value == "false": + return False + + # For other types, use Python's truthiness + # This handles cases where nested intrinsics return non-boolean values + return bool(value) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py b/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py new file mode 100644 index 00000000000..b0e4c461748 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py @@ -0,0 +1,95 @@ +""" +Fn::Base64 intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Base64 intrinsic +function, which encodes a string to base64. + +Requirements: + - 10.8: THE Resolver SHALL support Fn::Base64 for base64 encoding strings +""" + +import base64 +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnBase64Resolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Base64 intrinsic function. + + Fn::Base64 encodes a string to base64. It can be applied to: + - A literal string: {"Fn::Base64": "hello"} -> "aGVsbG8=" + - A nested intrinsic that resolves to a string: {"Fn::Base64": {"Ref": "MyParam"}} + + The resolver first resolves any nested intrinsic functions, then validates + that the result is a string, and finally returns the base64-encoded value. + + Attributes: + FUNCTION_NAMES: List containing "Fn::Base64" + + Example: + >>> resolver = FnBase64Resolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Base64": "hello"}) + 'aGVsbG8=' + >>> resolver.resolve({"Fn::Base64": {"Ref": "MyStringParam"}}) + # Returns base64-encoded value of the resolved parameter + + Raises: + InvalidTemplateException: If the resolved value is not a string. + + Requirements: + - 10.8: Support Fn::Base64 for base64 encoding strings + """ + + FUNCTION_NAMES = ["Fn::Base64"] + + def resolve(self, value: Dict[str, Any]) -> str: + """ + Resolve the Fn::Base64 intrinsic function. + + This method extracts the arguments from the Fn::Base64 function, + resolves any nested intrinsic functions, validates that the result + is a string, and returns the base64-encoded value. + + Args: + value: A dictionary representing the Fn::Base64 intrinsic function. + E.g., {"Fn::Base64": "hello"} or + {"Fn::Base64": {"Ref": "MyStringParam"}} + + Returns: + The base64-encoded string. + + Raises: + InvalidTemplateException: If the resolved value is not a string. + Error message: "Fn::Base64 layout is incorrect" + + Example: + >>> resolver.resolve({"Fn::Base64": "hello"}) + 'aGVsbG8=' + >>> resolver.resolve({"Fn::Base64": ""}) + '' + >>> resolver.resolve({"Fn::Base64": 123}) + InvalidTemplateException: Fn::Base64 layout is incorrect + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # First resolve any nested intrinsic functions + # This handles cases like {"Fn::Base64": {"Ref": "MyStringParam"}} + # or {"Fn::Base64": {"Fn::Sub": "Hello ${Name}"}} + if self.parent is not None: + resolved_args = self.parent.resolve_value(args) + else: + # If no parent resolver, use args as-is (for testing) + resolved_args = args + + # Validate that the resolved value is a string + if not isinstance(resolved_args, str): + raise InvalidTemplateException("Fn::Base64 layout is incorrect") + + # Encode the string to base64 + # CloudFormation uses UTF-8 encoding for the input string + encoded_bytes = base64.b64encode(resolved_args.encode("utf-8")) + return encoded_bytes.decode("utf-8") diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py new file mode 100644 index 00000000000..1affda98c82 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py @@ -0,0 +1,256 @@ +""" +Fn::FindInMap intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::FindInMap intrinsic +function, which looks up values in the Mappings section of a template. + +The resolver supports the AWS::LanguageExtensions enhancement that allows +specifying a DefaultValue to return when the map lookup fails. + +Requirements: + - 5.1: WHEN Fn::FindInMap is applied with valid map name, top-level key, and + second-level key, THEN THE Resolver SHALL return the corresponding + value from the Mappings section + - 5.2: WHEN Fn::FindInMap includes a DefaultValue option and the top-level key + is not found, THEN THE Resolver SHALL return the default value + - 5.3: WHEN Fn::FindInMap includes a DefaultValue option and the second-level key + is not found, THEN THE Resolver SHALL return the default value + - 5.4: WHEN Fn::FindInMap keys contain nested intrinsic functions (Fn::Select, + Fn::Split, Fn::If, Fn::Join, Fn::Sub), THEN THE Resolver SHALL resolve + those intrinsics before performing the lookup + - 5.5: WHEN Fn::FindInMap is applied with an invalid layout, THEN THE Resolver + SHALL raise an Invalid_Template_Exception + - 5.6: WHEN Fn::FindInMap lookup fails without a DefaultValue, THEN THE Resolver + SHALL raise an Invalid_Template_Exception +""" + +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnFindInMapResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::FindInMap intrinsic function with optional DefaultValue support. + + Fn::FindInMap returns the value corresponding to keys in a two-level map + declared in the Mappings section of a template. + + Standard format: + {"Fn::FindInMap": [MapName, TopLevelKey, SecondLevelKey]} + + With DefaultValue (AWS::LanguageExtensions): + {"Fn::FindInMap": [MapName, TopLevelKey, SecondLevelKey, {"DefaultValue": value}]} + + The resolver: + - Resolves any nested intrinsic functions in the keys before lookup + - Performs the map lookup with resolved keys + - Returns the DefaultValue if provided and lookup fails + - Raises InvalidTemplateException if lookup fails without DefaultValue + + Attributes: + FUNCTION_NAMES: List containing "Fn::FindInMap" + + Example: + >>> # Given Mappings: {"RegionMap": {"us-east-1": {"AMI": "ami-12345"}}} + >>> resolver = FnFindInMapResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}) + "ami-12345" + + >>> # With DefaultValue when key not found + >>> resolver.resolve({ + ... "Fn::FindInMap": ["RegionMap", "invalid-region", "AMI", + ... {"DefaultValue": "ami-default"}] + ... }) + "ami-default" + + Raises: + InvalidTemplateException: If the layout is incorrect or lookup fails + without a DefaultValue. + + Requirements: + - 5.1: Return the corresponding value from the Mappings section + - 5.2: Return DefaultValue when top-level key is not found + - 5.3: Return DefaultValue when second-level key is not found + - 5.4: Resolve nested intrinsics in keys before lookup + - 5.5: Raise InvalidTemplateException for invalid layout + - 5.6: Raise InvalidTemplateException when lookup fails without DefaultValue + """ + + FUNCTION_NAMES = ["Fn::FindInMap"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the Fn::FindInMap intrinsic function. + + This method extracts the arguments from the Fn::FindInMap function, + resolves any nested intrinsic functions in the keys, performs the + map lookup, and returns the result or DefaultValue. + + Args: + value: A dictionary representing the Fn::FindInMap intrinsic function. + E.g., {"Fn::FindInMap": ["MapName", "TopKey", "SecondKey"]} or + {"Fn::FindInMap": ["MapName", "TopKey", "SecondKey", + {"DefaultValue": "fallback"}]} + + Returns: + The value from the Mappings section, or the DefaultValue if provided + and the lookup fails. + + Raises: + InvalidTemplateException: If the layout is incorrect (not a list, + fewer than 3 elements) or if the lookup + fails without a DefaultValue. + + Example: + >>> resolver.resolve({"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}) + "ami-12345" + >>> resolver.resolve({"Fn::FindInMap": ["RegionMap", "invalid", "AMI", + ... {"DefaultValue": "default"}]}) + "default" + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate basic layout - must be a list with at least 3 elements + if not isinstance(args, list) or len(args) < 3: + raise InvalidTemplateException("Fn::FindInMap layout is incorrect") + + # Extract the map name, top-level key, and second-level key + map_name_arg = args[0] + top_key_arg = args[1] + second_key_arg = args[2] + + # Resolve any nested intrinsic functions in the keys + # This handles cases like {"Fn::FindInMap": [{"Ref": "MapParam"}, ...]} + if self.parent is not None: + map_name = self.parent.resolve_value(map_name_arg) + top_key = self.parent.resolve_value(top_key_arg) + second_key = self.parent.resolve_value(second_key_arg) + else: + # If no parent resolver, use args as-is (for testing) + map_name = map_name_arg + top_key = top_key_arg + second_key = second_key_arg + + # Validate resolved keys are strings + if not isinstance(map_name, str): + raise InvalidTemplateException("Fn::FindInMap layout is incorrect") + if not isinstance(top_key, str): + raise InvalidTemplateException("Fn::FindInMap layout is incorrect") + if not isinstance(second_key, str): + raise InvalidTemplateException("Fn::FindInMap layout is incorrect") + + # Check for DefaultValue option (4th argument) + default_value: Any = None + has_default = False + if len(args) >= 4: + options = args[3] + if isinstance(options, dict) and "DefaultValue" in options: + has_default = True + default_value = options["DefaultValue"] + elif options is not None and not isinstance(options, dict): + # If 4th argument exists and is not a dict, it's invalid + raise InvalidTemplateException("Fn::FindInMap layout is incorrect") + # If 4th argument is a dict without DefaultValue, treat as no default + + # Get the Mappings section from the parsed template + mappings = self._get_mappings() + + # Perform the lookup + try: + # Check if map exists + if map_name not in mappings: + if has_default: + return self._resolve_default_value(default_value) + raise InvalidTemplateException(f"Fn::FindInMap cannot find map '{map_name}' in Mappings") + + map_data = mappings[map_name] + + # Check if top-level key exists + if not isinstance(map_data, dict) or top_key not in map_data: + if has_default: + return self._resolve_default_value(default_value) + raise InvalidTemplateException(f"Fn::FindInMap cannot find key '{top_key}' in map '{map_name}'") + + top_level_data = map_data[top_key] + + # Check if second-level key exists + if not isinstance(top_level_data, dict) or second_key not in top_level_data: + if has_default: + return self._resolve_default_value(default_value) + raise InvalidTemplateException( + f"Fn::FindInMap cannot find key '{second_key}' in " f"map '{map_name}' under key '{top_key}'" + ) + + # Get the found value + result = top_level_data[second_key] + + # Treat null values as "not found" - Kotlin behavior + if result is None: + if has_default: + return self._resolve_default_value(default_value) + # Get resource type from context for error message + resource_type = self._get_current_resource_type() + raise InvalidTemplateException( + f"Mappings not found in template for key /{top_key}/{second_key} on resourceType {resource_type}" + ) + + return result + + except InvalidTemplateException: + # Re-raise InvalidTemplateException as-is + raise + except Exception as e: + # Wrap any other exceptions + if has_default: + return self._resolve_default_value(default_value) + raise InvalidTemplateException(f"Fn::FindInMap lookup failed: {e}") from e + + def _get_mappings(self) -> Dict[str, Any]: + """ + Get the Mappings section from the template context. + + Returns: + The Mappings dictionary from the parsed template, or an empty + dictionary if no parsed template or mappings are available. + """ + if self.context.parsed_template is not None: + return dict(self.context.parsed_template.mappings or {}) + + # Fallback to fragment if parsed_template not available + mappings = self.context.fragment.get("Mappings", {}) + return dict(mappings) if isinstance(mappings, dict) else {} + + def _resolve_default_value(self, default_value: Any) -> Any: + """ + Resolve the default value, handling any nested intrinsic functions. + + Args: + default_value: The default value to resolve. + + Returns: + The resolved default value. + """ + if self.parent is not None: + return self.parent.resolve_value(default_value) + return default_value + + def _get_current_resource_type(self) -> str: + """ + Get the resource type from the current context. + + This is used for error messages to match Kotlin behavior. + + Returns: + The resource type string, or "Unknown" if not available. + """ + # Try to get from fragment's Resources section + fragment = self.context.fragment + if "Resources" in fragment and isinstance(fragment["Resources"], dict): + # Return the first resource type found (best effort) + for resource in fragment["Resources"].values(): + if isinstance(resource, dict) and "Type" in resource: + return str(resource["Type"]) + return "Unknown" diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_if.py b/samcli/lib/cfn_language_extensions/resolvers/fn_if.py new file mode 100644 index 00000000000..92009cbd0c4 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_if.py @@ -0,0 +1,244 @@ +""" +Fn::If intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::If intrinsic +function, which returns one of two values based on a condition. + +Fn::If format: {"Fn::If": [condition_name, value_if_true, value_if_false]} + +Requirements: + - 8.2: WHEN Fn::If references a condition, THEN THE Resolver SHALL evaluate + the condition and return the appropriate branch + - 8.3: WHEN Fn::If references a non-existent condition, THEN THE Resolver + SHALL raise an Invalid_Template_Exception + - 8.5: WHEN a resource has a Condition attribute, THEN THE Resolver SHALL + only process the resource if the condition evaluates to true +""" + +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + +# Special AWS::NoValue reference that indicates a property should be removed +AWS_NO_VALUE = "AWS::NoValue" + + +class FnIfResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::If intrinsic function. + + Fn::If returns one of two values based on a condition. The format is: + {"Fn::If": [condition_name, value_if_true, value_if_false]} + + The resolver: + - Looks up the condition by name in resolved_conditions or evaluates it + - Returns value_if_true if the condition is true + - Returns value_if_false if the condition is false + - Handles AWS::NoValue special case (returns None to indicate removal) + - Raises InvalidTemplateException for non-existent conditions + + Attributes: + FUNCTION_NAMES: List containing "Fn::If" + + Example: + >>> resolver = FnIfResolver(context, parent_resolver) + >>> # With condition "IsProduction" = True + >>> resolver.resolve({"Fn::If": ["IsProduction", "prod-value", "dev-value"]}) + "prod-value" + >>> # With condition "IsProduction" = False + >>> resolver.resolve({"Fn::If": ["IsProduction", "prod-value", "dev-value"]}) + "dev-value" + + Raises: + InvalidTemplateException: If the layout is incorrect or the condition + doesn't exist. + + Requirements: + - 8.2: Evaluate condition and return appropriate branch + - 8.3: Raise exception for non-existent condition + - 8.5: Support conditional resource processing + """ + + FUNCTION_NAMES = ["Fn::If"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the Fn::If intrinsic function. + + This method extracts the condition name and two value branches from + the Fn::If function, evaluates the condition, and returns the + appropriate branch value. + + Args: + value: A dictionary representing the Fn::If intrinsic function. + E.g., {"Fn::If": ["IsProduction", "prod", "dev"]} + + Returns: + The value_if_true if condition is true, value_if_false otherwise. + Returns None if the selected branch is a Ref to AWS::NoValue. + + Raises: + InvalidTemplateException: If the layout is incorrect (not a list + of exactly 3 elements) or if the + condition doesn't exist. + Error message: "Fn::If layout is incorrect" + or "Condition '{name}' not found" + + Example: + >>> # With IsProduction = True + >>> resolver.resolve({"Fn::If": ["IsProduction", "prod", "dev"]}) + "prod" + >>> # With IsProduction = False + >>> resolver.resolve({"Fn::If": ["IsProduction", "prod", "dev"]}) + "dev" + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate the layout: must be a list with exactly 3 elements + # [condition_name, value_if_true, value_if_false] + if not isinstance(args, list) or len(args) != 3: + raise InvalidTemplateException("Fn::If layout is incorrect") + + condition_name = args[0] + value_if_true = args[1] + value_if_false = args[2] + + # Validate condition name is a string + if not isinstance(condition_name, str): + raise InvalidTemplateException("Fn::If layout is incorrect") + + # Evaluate the condition + condition_result = self._evaluate_condition(condition_name) + + # Select the appropriate branch based on condition result + # IMPORTANT: Only resolve the selected branch, NOT the unselected branch + # This is critical for templates where the unselected branch contains + # intrinsic functions that would fail if evaluated (e.g., Fn::Select + # with an out-of-bounds index that's only valid when the condition is true) + if condition_result: + selected_value = value_if_true + else: + selected_value = value_if_false + + # Handle AWS::NoValue special case + # If the selected value is {"Ref": "AWS::NoValue"}, return None + # to indicate the property should be removed + if self._is_no_value_ref(selected_value): + return None + + # Resolve any nested intrinsic functions in the selected value ONLY + if self.parent is not None: + return self.parent.resolve_value(selected_value) + + return selected_value + + def _evaluate_condition(self, condition_name: str) -> bool: + """ + Evaluate a condition by name. + + This method looks up the condition in the resolved_conditions cache + first. If not found, it attempts to evaluate the condition from + the template's Conditions section. + + Args: + condition_name: The name of the condition to evaluate. + + Returns: + The boolean result of the condition evaluation. + + Raises: + InvalidTemplateException: If the condition doesn't exist or + circular references are detected. + + Requirement 8.3: Raise exception for non-existent condition + """ + # Check for circular reference using context's tracking set + if condition_name in self.context._evaluating_conditions: + raise InvalidTemplateException(f"Circular condition reference detected: {condition_name}") + + # Check if condition is already resolved in context + if condition_name in self.context.resolved_conditions: + return self.context.resolved_conditions[condition_name] + + # Get the condition definition from parsed template + if self.context.parsed_template is None: + raise InvalidTemplateException(f"Condition '{condition_name}' not found") + + conditions = self.context.parsed_template.conditions + if condition_name not in conditions: + raise InvalidTemplateException(f"Condition '{condition_name}' not found") + + # Mark this condition as being evaluated (for circular reference detection) + self.context._evaluating_conditions.add(condition_name) + + try: + # Resolve the condition definition using the parent resolver + # This allows nested intrinsic functions in conditions to be resolved + condition_def = conditions[condition_name] + + if self.parent is not None: + resolved = self.parent.resolve_value(condition_def) + else: + resolved = condition_def + + # Convert to boolean + result = self._to_boolean(resolved) + + # Cache the result + self.context.resolved_conditions[condition_name] = result + + return result + finally: + # Remove from evaluating set + self.context._evaluating_conditions.discard(condition_name) + + def _to_boolean(self, value: Any) -> bool: + """ + Convert a value to a boolean. + + CloudFormation conditions can be: + - Boolean values (True/False) + - String "true"/"false" (case-insensitive) + + Args: + value: The value to convert. + + Returns: + The boolean representation of the value. + """ + if isinstance(value, bool): + return value + + if isinstance(value, str): + lower_value = value.lower() + if lower_value == "true": + return True + elif lower_value == "false": + return False + + # For other types, use Python's truthiness + return bool(value) + + def _is_no_value_ref(self, value: Any) -> bool: + """ + Check if a value is a Ref to AWS::NoValue. + + AWS::NoValue is a special pseudo-parameter that indicates a property + should be removed from the template. When Fn::If returns AWS::NoValue, + the property containing the Fn::If should be removed. + + Args: + value: The value to check. + + Returns: + True if the value is {"Ref": "AWS::NoValue"}, False otherwise. + """ + if not isinstance(value, dict): + return False + + if len(value) != 1: + return False + + return value.get("Ref") == AWS_NO_VALUE diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_join.py b/samcli/lib/cfn_language_extensions/resolvers/fn_join.py new file mode 100644 index 00000000000..1dd389e4d13 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_join.py @@ -0,0 +1,138 @@ +""" +Fn::Join intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Join intrinsic +function, which joins list elements with a delimiter. + +Fn::Join format: {"Fn::Join": [delimiter, [list, of, items]]} + +Requirements: + - 10.3: WHEN Fn::Join is applied to a list with a delimiter, THEN THE + Resolver SHALL return a string with all list elements joined by + the delimiter +""" + +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnJoinResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Join intrinsic function. + + Fn::Join concatenates a list of strings with a specified delimiter. + The format is: {"Fn::Join": [delimiter, [list, of, items]]} + + The resolver: + - First resolves any nested intrinsic functions in the list items + - Joins all list elements with the specified delimiter + - Returns the resulting string + + Attributes: + FUNCTION_NAMES: List containing "Fn::Join" + + Example: + >>> resolver = FnJoinResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Join": [",", ["a", "b", "c"]]}) + "a,b,c" + >>> resolver.resolve({"Fn::Join": ["-", ["2023", "01", "15"]]}) + "2023-01-15" + + Raises: + InvalidTemplateException: If the layout is incorrect (not a list of + [delimiter, list] or list items are not strings). + + Requirements: + - 10.3: Return a string with all list elements joined by the delimiter + """ + + FUNCTION_NAMES = ["Fn::Join"] + + def resolve(self, value: Dict[str, Any]) -> str: + """ + Resolve the Fn::Join intrinsic function. + + This method extracts the delimiter and list from the Fn::Join function, + resolves any nested intrinsic functions in the list items, and joins + them with the delimiter. + + Args: + value: A dictionary representing the Fn::Join intrinsic function. + E.g., {"Fn::Join": [",", ["a", "b", "c"]]} + + Returns: + A string with all list elements joined by the delimiter. + + Raises: + InvalidTemplateException: If the layout is incorrect. + Error message: "Fn::Join layout is incorrect" + + Example: + >>> resolver.resolve({"Fn::Join": [",", ["a", "b", "c"]]}) + "a,b,c" + >>> resolver.resolve({"Fn::Join": ["", ["Hello", "World"]]}) + "HelloWorld" + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate the layout: must be a list with exactly 2 elements + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Join layout is incorrect") + + delimiter = args[0] + list_to_join = args[1] + + # Resolve any nested intrinsic functions in the delimiter + if self.parent is not None: + delimiter = self.parent.resolve_value(delimiter) + + # Validate delimiter is a string + if not isinstance(delimiter, str): + raise InvalidTemplateException("Fn::Join layout is incorrect") + + # Resolve any nested intrinsic functions in the list + if self.parent is not None: + list_to_join = self.parent.resolve_value(list_to_join) + + # Validate the list + if not isinstance(list_to_join, list): + raise InvalidTemplateException("Fn::Join layout is incorrect") + + # Convert all items to strings and join + string_items = [] + for item in list_to_join: + string_items.append(self._to_string(item)) + + return delimiter.join(string_items) + + def _to_string(self, value: Any) -> str: + """ + Convert a value to string for joining. + + Args: + value: The value to convert. + + Returns: + The string representation. + """ + if isinstance(value, str): + return value + elif isinstance(value, bool): + # CloudFormation uses lowercase for booleans + return "true" if value else "false" + elif isinstance(value, (int, float)): + return str(value) + elif value is None: + return "" + elif isinstance(value, dict): + # If it's an unresolved intrinsic, convert to string representation + # This handles cases where intrinsics couldn't be resolved + return str(value) + elif isinstance(value, list): + # Nested lists - join with comma as default + return ",".join(self._to_string(item) for item in value) + else: + return str(value) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_length.py b/samcli/lib/cfn_language_extensions/resolvers/fn_length.py new file mode 100644 index 00000000000..2a7dae4fdbf --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_length.py @@ -0,0 +1,116 @@ +""" +Fn::Length intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Length intrinsic +function, which returns the number of elements in a list. + +Requirements: + - 3.1: WHEN Fn::Length is applied to a list, THEN THE Resolver SHALL return + the number of elements in the list + - 3.2: WHEN Fn::Length is applied to a nested intrinsic function that resolves + to a list, THEN THE Resolver SHALL first resolve the inner function + and then return the length + - 3.3: WHEN Fn::Length is applied to a non-list value, THEN THE Resolver SHALL + raise an Invalid_Template_Exception indicating incorrect layout + - 3.4: WHEN Fn::Length references a parameter that resolves to a CommaDelimitedList, + THEN THE Resolver SHALL return the count of items in the list +""" + +from typing import Any, Dict, Union + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnLengthResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Length intrinsic function. + + Fn::Length returns the number of elements in a list. It can be applied to: + - A literal list: {"Fn::Length": [1, 2, 3]} -> 3 + - A nested intrinsic that resolves to a list: {"Fn::Length": {"Ref": "MyList"}} + - A parameter that is a CommaDelimitedList + + The resolver first resolves any nested intrinsic functions, then validates + that the result is a list, and finally returns the length. + + Attributes: + FUNCTION_NAMES: List containing "Fn::Length" + + Example: + >>> resolver = FnLengthResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Length": [1, 2, 3]}) + 3 + >>> resolver.resolve({"Fn::Length": {"Ref": "MyListParam"}}) + # Returns length of the resolved parameter value + + Raises: + InvalidTemplateException: If the resolved value is not a list. + + Requirements: + - 3.1: Return the number of elements in a list + - 3.2: Resolve nested intrinsics first, then return length + - 3.3: Raise InvalidTemplateException for non-list input + - 3.4: Handle CommaDelimitedList parameters + """ + + FUNCTION_NAMES = ["Fn::Length"] + + def resolve(self, value: Dict[str, Any]) -> Union[int, Dict[str, Any]]: + """ + Resolve the Fn::Length intrinsic function. + + This method extracts the arguments from the Fn::Length function, + resolves any nested intrinsic functions, validates that the result + is a list, and returns the length. + + Args: + value: A dictionary representing the Fn::Length intrinsic function. + E.g., {"Fn::Length": [1, 2, 3]} or + {"Fn::Length": {"Ref": "MyListParam"}} + + Returns: + The number of elements in the resolved list. + + Raises: + InvalidTemplateException: If the resolved value is not a list. + Error message: "Fn::Length layout is incorrect" + + Example: + >>> resolver.resolve({"Fn::Length": [1, 2, 3]}) + 3 + >>> resolver.resolve({"Fn::Length": []}) + 0 + >>> resolver.resolve({"Fn::Length": "not-a-list"}) + InvalidTemplateException: Fn::Length layout is incorrect + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # First resolve any nested intrinsic functions + # This handles cases like {"Fn::Length": {"Ref": "MyListParam"}} + # or {"Fn::Length": {"Fn::Split": [",", "a,b,c"]}} + if self.parent is not None: + resolved_args = self.parent.resolve_value(args) + else: + # If no parent resolver, use args as-is (for testing) + resolved_args = args + + # If the resolved value is still an intrinsic function (unresolved), + # preserve the Fn::Length for later resolution. + # An intrinsic function is a dict with exactly one key that starts with + # "Fn::" or is "Ref" or "Condition". + if isinstance(resolved_args, dict): + if len(resolved_args) == 1: + key = next(iter(resolved_args.keys())) + if key.startswith("Fn::") or key in ("Ref", "Condition"): + return {"Fn::Length": resolved_args} + # If it's a dict but not an intrinsic function, raise error + raise InvalidTemplateException("Fn::Length layout is incorrect") + + # Validate that the resolved value is a list + if not isinstance(resolved_args, list): + raise InvalidTemplateException("Fn::Length layout is incorrect") + + # Return the length of the list + return len(resolved_args) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_ref.py b/samcli/lib/cfn_language_extensions/resolvers/fn_ref.py new file mode 100644 index 00000000000..ca5947a512d --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_ref.py @@ -0,0 +1,254 @@ +""" +Fn::Ref intrinsic function resolver. + +This module provides the resolver for the CloudFormation Ref intrinsic +function, which returns the value of a parameter, pseudo-parameter, or +resource reference. + +Requirements: + - 10.1: WHEN Ref is applied to a template parameter, THEN THE Resolver SHALL + return the parameter's value from the context + - 9.2: WHEN a pseudo-parameter (AWS::Region, AWS::AccountId, etc.) is referenced, + THEN THE Resolver SHALL return the value from the PseudoParameterValues + if provided + - 9.3: WHEN a pseudo-parameter is referenced but no value is provided, THEN THE + Resolver SHALL preserve the reference unresolved +""" + +from typing import Any, Dict, Optional + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + +# Set of AWS pseudo-parameters that can be resolved +PSEUDO_PARAMETERS = { + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", +} + + +class FnRefResolver(IntrinsicFunctionResolver): + """ + Resolves Ref intrinsic function. + + Ref returns the value of a specified parameter, pseudo-parameter, or + resource. This resolver handles: + - Template parameters: Returns the value from context.parameter_values + - Pseudo-parameters: Returns the value from context.pseudo_parameters + - Resource references: Preserved in partial mode (cannot be resolved locally) + + Attributes: + FUNCTION_NAMES: List containing "Ref" + + Example: + >>> resolver = FnRefResolver(context, parent_resolver) + >>> resolver.resolve({"Ref": "MyParameter"}) + # Returns the value of MyParameter from context.parameter_values + >>> resolver.resolve({"Ref": "AWS::Region"}) + # Returns the region from context.pseudo_parameters + + Requirements: + - 10.1: Return parameter value from context for parameter references + - 9.2: Return pseudo-parameter value if provided + - 9.3: Preserve pseudo-parameter reference if no value provided + """ + + FUNCTION_NAMES = ["Ref"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the Ref intrinsic function. + + This method extracts the reference target from the Ref function and + resolves it based on the target type: + - If it's a template parameter, returns the parameter value + - If it's a pseudo-parameter with a provided value, returns that value + - If it's a pseudo-parameter without a value, preserves the Ref + - If it's a resource reference, preserves the Ref (in partial mode) + + Args: + value: A dictionary representing the Ref intrinsic function. + E.g., {"Ref": "MyParameter"} or {"Ref": "AWS::Region"} + + Returns: + The resolved value for parameters and pseudo-parameters, + or the original Ref dict for unresolvable references. + + Raises: + InvalidTemplateException: If the Ref target is invalid. + + Example: + >>> resolver.resolve({"Ref": "MyParameter"}) + "parameter-value" + >>> resolver.resolve({"Ref": "AWS::Region"}) + "us-east-1" + >>> resolver.resolve({"Ref": "MyResource"}) + {"Ref": "MyResource"} # Preserved in partial mode + """ + # Extract the reference target + ref_target = self.get_function_args(value) + + # If ref_target is an intrinsic function (dict), resolve it first + if isinstance(ref_target, dict) and self.parent is not None: + ref_target = self.parent.resolve_value(ref_target) + + # Validate that ref_target is a string + if not isinstance(ref_target, str): + raise InvalidTemplateException("Ref layout is incorrect") + + # Try to resolve as a template parameter first + param_value = self._resolve_parameter(ref_target) + if param_value is not None: + return param_value + + # Try to resolve as a pseudo-parameter + pseudo_value = self._resolve_pseudo_parameter(ref_target) + if pseudo_value is not None: + return pseudo_value + + # Check if it's a pseudo-parameter without a provided value + if ref_target in PSEUDO_PARAMETERS: + # Preserve the reference unresolved (Requirement 9.3) + return value + + # If not a parameter or pseudo-parameter, it's likely a resource reference + # In partial mode, preserve the reference + return value + + def _resolve_parameter(self, ref_target: str) -> Optional[Any]: + """ + Attempt to resolve a reference as a template parameter. + + Args: + ref_target: The reference target string. + + Returns: + The parameter value if found, None otherwise. + + Requirements: + - 10.1: Return parameter value from context + """ + # Check parameter_values in context + if ref_target in self.context.parameter_values: + value = self.context.parameter_values[ref_target] + # Convert comma-separated string to list for List type parameters + return self._convert_list_parameter(ref_target, value) + + # Check parsed_template parameters if available + if self.context.parsed_template is not None: + if ref_target in self.context.parsed_template.parameters: + # Parameter exists but no value provided - check for default + param_def = self.context.parsed_template.parameters[ref_target] + if isinstance(param_def, dict) and "Default" in param_def: + value = param_def["Default"] + # Convert comma-separated string to list for List type parameters + return self._convert_list_parameter(ref_target, value) + + return None + + def _convert_list_parameter(self, param_name: str, value: Any) -> Any: + """ + Convert a parameter value to a list if the parameter type is a List type. + + CloudFormation List parameters can have comma-separated string defaults + that need to be converted to actual lists. + + Args: + param_name: The parameter name. + value: The parameter value. + + Returns: + The value converted to a list if appropriate, otherwise unchanged. + """ + # If already a list, return as-is + if isinstance(value, list): + return value + + # Check if this parameter is a List type + if self.context.parsed_template is not None: + if param_name in self.context.parsed_template.parameters: + param_def = self.context.parsed_template.parameters[param_name] + if isinstance(param_def, dict): + param_type = param_def.get("Type", "") + # List types in CloudFormation start with "List<" or are + # "CommaDelimitedList" or "AWS::SSM::Parameter::Value>" + if param_type.startswith("List<") or param_type == "CommaDelimitedList" or "List<" in param_type: + # Convert comma-separated string to list + if isinstance(value, str): + return [v.strip() for v in value.split(",")] + + return value + + def _resolve_pseudo_parameter(self, ref_target: str) -> Optional[Any]: + """ + Attempt to resolve a reference as a pseudo-parameter. + + Args: + ref_target: The reference target string. + + Returns: + The pseudo-parameter value if found and provided, None otherwise. + + Requirements: + - 9.2: Return pseudo-parameter value if provided + """ + if self.context.pseudo_parameters is None: + return None + + pseudo = self.context.pseudo_parameters + + # Map pseudo-parameter names to their values + pseudo_map = { + "AWS::AccountId": pseudo.account_id, + "AWS::Region": pseudo.region, + "AWS::StackId": pseudo.stack_id, + "AWS::StackName": pseudo.stack_name, + "AWS::NotificationARNs": pseudo.notification_arns, + "AWS::Partition": pseudo.partition or self._derive_partition(pseudo.region), + "AWS::URLSuffix": pseudo.url_suffix or self._derive_url_suffix(pseudo.region), + } + + if ref_target in pseudo_map: + value = pseudo_map[ref_target] + if value is not None: + return value + + return None + + def _derive_partition(self, region: str) -> str: + """ + Derive the AWS partition from the region. + + Args: + region: The AWS region string. + + Returns: + The partition string (aws, aws-cn, or aws-us-gov). + """ + if region.startswith("cn-"): + return "aws-cn" + elif region.startswith("us-gov-"): + return "aws-us-gov" + else: + return "aws" + + def _derive_url_suffix(self, region: str) -> str: + """ + Derive the AWS URL suffix from the region. + + Args: + region: The AWS region string. + + Returns: + The URL suffix string. + """ + if region.startswith("cn-"): + return "amazonaws.com.cn" + else: + return "amazonaws.com" diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_select.py b/samcli/lib/cfn_language_extensions/resolvers/fn_select.py new file mode 100644 index 00000000000..b7053fe66dd --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_select.py @@ -0,0 +1,133 @@ +""" +Fn::Select intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Select intrinsic +function, which selects an item from a list by index. + +Fn::Select format: {"Fn::Select": [index, [list, of, items]]} + +Requirements: + - 10.5: WHEN Fn::Select is applied to a list with an index, THEN THE + Resolver SHALL return the element at that index + - 10.9: WHEN Fn::Select is applied with an out-of-bounds index, THEN THE + Resolver SHALL raise an Invalid_Template_Exception +""" + +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnSelectResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Select intrinsic function. + + Fn::Select selects an item from a list by its 0-based index. + The format is: {"Fn::Select": [index, [list, of, items]]} + + The resolver: + - First resolves any nested intrinsic functions in the list + - Selects the item at the specified index + - Returns the selected item + - Raises InvalidTemplateException for out-of-bounds index + + Attributes: + FUNCTION_NAMES: List containing "Fn::Select" + + Example: + >>> resolver = FnSelectResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Select": [0, ["a", "b", "c"]]}) + "a" + >>> resolver.resolve({"Fn::Select": [2, ["a", "b", "c"]]}) + "c" + + Raises: + InvalidTemplateException: If the layout is incorrect (not a list of + [index, list]) or if the index is out of bounds. + + Requirements: + - 10.5: Return the element at the specified index + - 10.9: Raise exception for out-of-bounds index + """ + + FUNCTION_NAMES = ["Fn::Select"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the Fn::Select intrinsic function. + + This method extracts the index and list from the Fn::Select function, + resolves any nested intrinsic functions, and returns the item at the + specified index. + + Args: + value: A dictionary representing the Fn::Select intrinsic function. + E.g., {"Fn::Select": [0, ["a", "b", "c"]]} + + Returns: + The item at the specified index in the list. + + Raises: + InvalidTemplateException: If the layout is incorrect or index is + out of bounds. + Error message: "Fn::Select layout is incorrect" + or "Fn::Select index out of bounds" + + Example: + >>> resolver.resolve({"Fn::Select": [0, ["a", "b", "c"]]}) + "a" + >>> resolver.resolve({"Fn::Select": [1, ["x", "y"]]}) + "y" + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate the layout: must be a list with exactly 2 elements + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Select layout is incorrect") + + index = args[0] + source_list = args[1] + + # Resolve any nested intrinsic functions in the index + if self.parent is not None: + index = self.parent.resolve_value(index) + + # Validate index is an integer (or can be converted to one) + if isinstance(index, str): + try: + index = int(index) + except ValueError: + raise InvalidTemplateException("Fn::Select layout is incorrect") + elif not isinstance(index, int): + raise InvalidTemplateException("Fn::Select layout is incorrect") + + # Resolve any nested intrinsic functions in the source list + if self.parent is not None: + source_list = self.parent.resolve_value(source_list) + + # If the source list is still an intrinsic function (unresolved), preserve the Fn::Select. + # An intrinsic function is a dict with exactly one key that starts with + # "Fn::" or is "Ref" or "Condition". + if isinstance(source_list, dict): + if len(source_list) == 1: + key = next(iter(source_list.keys())) + if key.startswith("Fn::") or key in ("Ref", "Condition"): + # Return the original value with resolved index if possible + return {"Fn::Select": [index, source_list]} + # If it's a dict but not an intrinsic function, raise error + raise InvalidTemplateException("Fn::Select layout is incorrect") + + # Validate the source list + if not isinstance(source_list, list): + raise InvalidTemplateException("Fn::Select layout is incorrect") + + # Check for out-of-bounds index + # Requirement 10.9: Raise exception for out-of-bounds index + if index < 0 or index >= len(source_list): + raise InvalidTemplateException("Fn::Select index out of bounds") + + # Return the item at the specified index + # Requirement 10.5: Return the element at that index + return source_list[index] diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_split.py b/samcli/lib/cfn_language_extensions/resolvers/fn_split.py new file mode 100644 index 00000000000..c248c051155 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_split.py @@ -0,0 +1,118 @@ +""" +Fn::Split intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Split intrinsic +function, which splits a string by a delimiter into a list. + +Fn::Split format: {"Fn::Split": [delimiter, "string-to-split"]} + +Requirements: + - 10.4: WHEN Fn::Split is applied to a string with a delimiter, THEN THE + Resolver SHALL return a list of strings split by the delimiter +""" + +from typing import Any, Dict, List, Union + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnSplitResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Split intrinsic function. + + Fn::Split splits a string into a list of strings using a specified delimiter. + The format is: {"Fn::Split": [delimiter, "string-to-split"]} + + The resolver: + - First resolves any nested intrinsic functions in the source string + - Splits the string by the specified delimiter + - Returns the resulting list of strings + + Attributes: + FUNCTION_NAMES: List containing "Fn::Split" + + Example: + >>> resolver = FnSplitResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Split": [",", "a,b,c"]}) + ["a", "b", "c"] + >>> resolver.resolve({"Fn::Split": ["-", "2023-01-15"]}) + ["2023", "01", "15"] + + Raises: + InvalidTemplateException: If the layout is incorrect (not a list of + [delimiter, string]). + + Requirements: + - 10.4: Return a list of strings split by the delimiter + """ + + FUNCTION_NAMES = ["Fn::Split"] + + def resolve(self, value: Dict[str, Any]) -> Union[List[str], Dict[str, Any]]: + """ + Resolve the Fn::Split intrinsic function. + + This method extracts the delimiter and source string from the Fn::Split + function, resolves any nested intrinsic functions, and splits the string + by the delimiter. + + Args: + value: A dictionary representing the Fn::Split intrinsic function. + E.g., {"Fn::Split": [",", "a,b,c"]} + + Returns: + A list of strings resulting from splitting the source string. + + Raises: + InvalidTemplateException: If the layout is incorrect. + Error message: "Fn::Split layout is incorrect" + + Example: + >>> resolver.resolve({"Fn::Split": [",", "a,b,c"]}) + ["a", "b", "c"] + >>> resolver.resolve({"Fn::Split": ["", "abc"]}) + ["a", "b", "c"] # Empty delimiter splits into characters + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate the layout: must be a list with exactly 2 elements + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Split layout is incorrect") + + delimiter = args[0] + source_string = args[1] + + # Resolve any nested intrinsic functions in the delimiter + if self.parent is not None: + delimiter = self.parent.resolve_value(delimiter) + + # Validate delimiter is a string + if not isinstance(delimiter, str): + raise InvalidTemplateException("Fn::Split layout is incorrect") + + # Reject empty delimiter - Kotlin throws error for this + if delimiter == "": + raise InvalidTemplateException("Fn::Split delimiter cannot be empty") + + # Resolve any nested intrinsic functions in the source string + if self.parent is not None: + source_string = self.parent.resolve_value(source_string) + + # If source_string is still an unresolved intrinsic, preserve the Fn::Split + if isinstance(source_string, dict): + # Check if it's an intrinsic function (single key starting with Fn:: or Ref) + if len(source_string) == 1: + key = next(iter(source_string.keys())) + if key.startswith("Fn::") or key == "Ref" or key == "Condition": + # Return the original Fn::Split with resolved delimiter + return {"Fn::Split": [delimiter, source_string]} + raise InvalidTemplateException("Fn::Split layout is incorrect") + + # Validate the source string + if not isinstance(source_string, str): + raise InvalidTemplateException("Fn::Split layout is incorrect") + + # Split the string by the delimiter + return source_string.split(delimiter) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_sub.py b/samcli/lib/cfn_language_extensions/resolvers/fn_sub.py new file mode 100644 index 00000000000..e69168f551d --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_sub.py @@ -0,0 +1,382 @@ +""" +Fn::Sub intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::Sub intrinsic +function, which performs string substitution with ${} placeholders. + +Fn::Sub supports two forms: +- Short form: {"Fn::Sub": "Hello ${Name}"} - substitutes from parameters/pseudo-parameters +- Long form: {"Fn::Sub": ["Hello ${Name}", {"Name": "World"}]} - uses variable map + +Requirements: + - 10.2: WHEN Fn::Sub is applied to a string with ${} placeholders, THEN THE + Resolver SHALL substitute the placeholders with the corresponding values + from parameters, pseudo-parameters, or the variable map +""" + +import re +from typing import Any, Dict, Optional, Tuple + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + +# Regex pattern to match ${VarName} or ${VarName.Attribute} placeholders +# Matches: ${Name}, ${AWS::Region}, ${MyResource.Arn} +PLACEHOLDER_PATTERN = re.compile(r"\$\{([^}]+)\}") + + +class FnSubResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::Sub intrinsic function. + + Fn::Sub substitutes variables in a string. It supports two forms: + + Short form: + {"Fn::Sub": "Hello ${Name}"} + Variables are resolved from parameters and pseudo-parameters. + + Long form: + {"Fn::Sub": ["Hello ${Name}", {"Name": "World"}]} + Variables are first looked up in the variable map, then in + parameters and pseudo-parameters. + + Variable syntax: + - ${VarName} - References a parameter, pseudo-parameter, or variable map entry + - ${Resource.Attribute} - References a resource attribute (preserved in partial mode) + - ${!Literal} - Literal ${Literal} (escape syntax) + + Attributes: + FUNCTION_NAMES: List containing "Fn::Sub" + + Example: + >>> resolver = FnSubResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::Sub": "Region is ${AWS::Region}"}) + "Region is us-east-1" + >>> resolver.resolve({"Fn::Sub": ["Hello ${Name}", {"Name": "World"}]}) + "Hello World" + + Requirements: + - 10.2: Substitute placeholders with values from parameters, + pseudo-parameters, or variable map + """ + + FUNCTION_NAMES = ["Fn::Sub"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """ + Resolve the Fn::Sub intrinsic function. + + This method handles both short form and long form Fn::Sub: + - Short form: {"Fn::Sub": "string with ${placeholders}"} + - Long form: {"Fn::Sub": ["string with ${placeholders}", {"Var": "value"}]} + + Args: + value: A dictionary representing the Fn::Sub intrinsic function. + + Returns: + The string with placeholders substituted. If some placeholders + cannot be resolved (e.g., resource attributes), they are preserved + in the output in partial mode. + + Raises: + InvalidTemplateException: If the Fn::Sub layout is incorrect. + + Example: + >>> resolver.resolve({"Fn::Sub": "Hello ${Name}"}) + "Hello World" # If Name parameter is "World" + """ + args = self.get_function_args(value) + + # Parse the arguments to get template string and variable map + template_string, variable_map = self._parse_args(args) + + # Resolve any intrinsic functions in the variable map values + resolved_variable_map = self._resolve_variable_map(variable_map) + + # Perform the substitution + return self._substitute(template_string, resolved_variable_map) + + def _parse_args(self, args: Any) -> Tuple[str, Dict[str, Any]]: + """ + Parse Fn::Sub arguments into template string and variable map. + + Args: + args: The Fn::Sub arguments (string or list). + + Returns: + A tuple of (template_string, variable_map). + + Raises: + InvalidTemplateException: If the arguments are invalid. + """ + # Short form: just a string + if isinstance(args, str): + return args, {} + + # Long form: [string, variable_map] + if isinstance(args, list): + if len(args) != 2: + raise InvalidTemplateException("Fn::Sub layout is incorrect") + + template_string = args[0] + variable_map = args[1] + + if not isinstance(template_string, str): + raise InvalidTemplateException("Fn::Sub layout is incorrect") + + if not isinstance(variable_map, dict): + raise InvalidTemplateException("Fn::Sub layout is incorrect") + + return template_string, variable_map + + # Invalid type + raise InvalidTemplateException("Fn::Sub layout is incorrect") + + def _resolve_variable_map(self, variable_map: Dict[str, Any]) -> Dict[str, Any]: + """ + Resolve intrinsic functions in variable map values. + + Args: + variable_map: The variable map from Fn::Sub long form. + + Returns: + A new dict with resolved values. + + Raises: + InvalidTemplateException: If a variable value is null. + """ + if not variable_map: + return {} + + resolved = {} + for key, val in variable_map.items(): + # Check for null values - Kotlin throws error for null substitution values + if val is None: + raise InvalidTemplateException(f"Fn::Sub variable '{key}' has null value") + # Use parent resolver to resolve any intrinsic functions + if self.parent is not None: + resolved[key] = self.parent.resolve_value(val) + else: + resolved[key] = val + + return resolved + + def _substitute(self, template_string: str, variable_map: Dict[str, Any]) -> str: + """ + Perform variable substitution in the template string. + + Args: + template_string: The string with ${} placeholders. + variable_map: The variable map for substitution. + + Returns: + The string with placeholders substituted. + """ + + def replace_placeholder(match: re.Match) -> str: + """Replace a single placeholder with its value.""" + var_name = str(match.group(1)) + + # Handle escape syntax: ${!Literal} -> ${Literal} + if var_name.startswith("!"): + return "${" + var_name[1:] + "}" + + # Try to resolve the variable + resolved_value = self._resolve_variable(var_name, variable_map) + + if resolved_value is not None: + # Convert to string for substitution + return self._value_to_string(resolved_value) + + # If not resolved, preserve the placeholder + return str(match.group(0)) + + result = PLACEHOLDER_PATTERN.sub(replace_placeholder, template_string) + return str(result) + + def _resolve_variable(self, var_name: str, variable_map: Dict[str, Any]) -> Optional[Any]: + """ + Resolve a variable name to its value. + + Resolution order: + 1. Variable map (from long form) + 2. Template parameters + 3. Pseudo-parameters + + For resource attributes (e.g., MyResource.Arn), returns None + to preserve the placeholder in partial mode. + + Args: + var_name: The variable name from the placeholder. + variable_map: The variable map from Fn::Sub long form. + + Returns: + The resolved value, or None if not resolvable. + """ + # Check if it's a resource attribute reference (contains a dot) + if "." in var_name: + # This is a GetAtt-style reference (e.g., MyResource.Arn) + # Check if it's in the variable map first + if var_name in variable_map: + return variable_map[var_name] + # Otherwise, cannot resolve locally - preserve it + return None + + # 1. Check variable map first + if var_name in variable_map: + return variable_map[var_name] + + # 2. Check template parameters + param_value = self._resolve_from_parameters(var_name) + if param_value is not None: + return param_value + + # 3. Check pseudo-parameters + pseudo_value = self._resolve_from_pseudo_parameters(var_name) + if pseudo_value is not None: + return pseudo_value + + # 4. Check if it's a known pseudo-parameter without a value + if self._is_pseudo_parameter(var_name): + # Preserve the placeholder + return None + + # 5. Assume it's a resource reference - preserve it + return None + + def _resolve_from_parameters(self, var_name: str) -> Optional[Any]: + """ + Attempt to resolve a variable from template parameters. + + Args: + var_name: The variable name. + + Returns: + The parameter value if found, None otherwise. + """ + # Check parameter_values in context + if var_name in self.context.parameter_values: + return self.context.parameter_values[var_name] + + # Check parsed_template parameters for default values + if self.context.parsed_template is not None: + if var_name in self.context.parsed_template.parameters: + param_def = self.context.parsed_template.parameters[var_name] + if isinstance(param_def, dict) and "Default" in param_def: + return param_def["Default"] + + return None + + def _resolve_from_pseudo_parameters(self, var_name: str) -> Optional[Any]: + """ + Attempt to resolve a variable from pseudo-parameters. + + Args: + var_name: The variable name. + + Returns: + The pseudo-parameter value if found and provided, None otherwise. + """ + if self.context.pseudo_parameters is None: + return None + + pseudo = self.context.pseudo_parameters + + # Map pseudo-parameter names to their values + pseudo_map = { + "AWS::AccountId": pseudo.account_id, + "AWS::Region": pseudo.region, + "AWS::StackId": pseudo.stack_id, + "AWS::StackName": pseudo.stack_name, + "AWS::NotificationARNs": pseudo.notification_arns, + "AWS::Partition": pseudo.partition or self._derive_partition(pseudo.region), + "AWS::URLSuffix": pseudo.url_suffix or self._derive_url_suffix(pseudo.region), + } + + if var_name in pseudo_map: + value = pseudo_map[var_name] + if value is not None: + return value + + return None + + def _is_pseudo_parameter(self, var_name: str) -> bool: + """ + Check if a variable name is a pseudo-parameter. + + Args: + var_name: The variable name. + + Returns: + True if it's a pseudo-parameter name. + """ + pseudo_params = { + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", + } + return var_name in pseudo_params + + def _derive_partition(self, region: str) -> str: + """ + Derive the AWS partition from the region. + + Args: + region: The AWS region string. + + Returns: + The partition string (aws, aws-cn, or aws-us-gov). + """ + if region.startswith("cn-"): + return "aws-cn" + elif region.startswith("us-gov-"): + return "aws-us-gov" + else: + return "aws" + + def _derive_url_suffix(self, region: str) -> str: + """ + Derive the AWS URL suffix from the region. + + Args: + region: The AWS region string. + + Returns: + The URL suffix string. + """ + if region.startswith("cn-"): + return "amazonaws.com.cn" + else: + return "amazonaws.com" + + def _value_to_string(self, value: Any) -> str: + """ + Convert a value to string for substitution. + + Args: + value: The value to convert. + + Returns: + The string representation. + """ + if isinstance(value, str): + return value + elif isinstance(value, bool): + # CloudFormation uses lowercase for booleans + return "true" if value else "false" + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, list): + # Join list elements with comma + return ",".join(self._value_to_string(item) for item in value) + elif isinstance(value, dict): + # If it's an unresolved intrinsic, we can't substitute it + # This shouldn't happen in normal flow, but handle gracefully + return str(value) + else: + return str(value) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_to_json_string.py b/samcli/lib/cfn_language_extensions/resolvers/fn_to_json_string.py new file mode 100644 index 00000000000..a4fe059a8a2 --- /dev/null +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_to_json_string.py @@ -0,0 +1,203 @@ +""" +Fn::ToJsonString intrinsic function resolver. + +This module provides the resolver for the CloudFormation Fn::ToJsonString intrinsic +function, which converts a dictionary or list to a JSON string representation. + +Requirements: + - 4.1: WHEN Fn::ToJsonString is applied to a dictionary, THEN THE Resolver SHALL + return a JSON string representation of that dictionary + - 4.2: WHEN Fn::ToJsonString is applied to a list, THEN THE Resolver SHALL + return a JSON string representation of that list + - 4.3: WHEN Fn::ToJsonString contains nested intrinsic functions that can be + resolved, THEN THE Resolver SHALL resolve those intrinsics before + converting to JSON + - 4.4: WHEN Fn::ToJsonString contains intrinsic functions that cannot be resolved + (e.g., Fn::GetAtt), THEN THE Resolver SHALL preserve those intrinsics + in the JSON output + - 4.5: WHEN Fn::ToJsonString is applied to an invalid layout, THEN THE Resolver + SHALL raise an Invalid_Template_Exception +""" + +import json +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver + + +class FnToJsonStringResolver(IntrinsicFunctionResolver): + """ + Resolves Fn::ToJsonString intrinsic function. + + Fn::ToJsonString converts a dictionary or list to a JSON string representation. + It can be applied to: + - A literal dictionary: {"Fn::ToJsonString": {"key": "value"}} -> '{"key":"value"}' + - A literal list: {"Fn::ToJsonString": [1, 2, 3]} -> '[1,2,3]' + - A nested intrinsic that resolves to a dict/list + + The resolver first resolves any nested intrinsic functions where possible, + then converts the result to a compact JSON string (no extra whitespace). + + Unresolvable intrinsic functions (like Fn::GetAtt) are preserved in the + JSON output as their original dictionary representation. + + Attributes: + FUNCTION_NAMES: List containing "Fn::ToJsonString" + + Example: + >>> resolver = FnToJsonStringResolver(context, parent_resolver) + >>> resolver.resolve({"Fn::ToJsonString": {"key": "value"}}) + '{"key":"value"}' + >>> resolver.resolve({"Fn::ToJsonString": [1, 2, 3]}) + '[1,2,3]' + >>> # With unresolvable intrinsic preserved + >>> resolver.resolve({"Fn::ToJsonString": {"arn": {"Fn::GetAtt": ["Bucket", "Arn"]}}}) + '{"arn":{"Fn::GetAtt":["Bucket","Arn"]}}' + + Raises: + InvalidTemplateException: If the input is not a dictionary or list. + + Requirements: + - 4.1: Return JSON string representation of dictionaries + - 4.2: Return JSON string representation of lists + - 4.3: Resolve nested intrinsics before converting to JSON + - 4.4: Preserve unresolvable intrinsics in JSON output + - 4.5: Raise InvalidTemplateException for invalid layout + """ + + FUNCTION_NAMES = ["Fn::ToJsonString"] + + def resolve(self, value: Dict[str, Any]) -> str: + """ + Resolve the Fn::ToJsonString intrinsic function. + + This method extracts the arguments from the Fn::ToJsonString function, + resolves any nested intrinsic functions where possible (preserving + unresolvable ones), validates that the result is a dictionary or list, + and returns a compact JSON string representation. + + Args: + value: A dictionary representing the Fn::ToJsonString intrinsic function. + E.g., {"Fn::ToJsonString": {"key": "value"}} or + {"Fn::ToJsonString": [1, 2, 3]} + + Returns: + A compact JSON string representation of the resolved value. + Uses separators=(',', ':') for minimal whitespace. + + Raises: + InvalidTemplateException: If the resolved value is not a dictionary or list. + Error message: "Fn::ToJsonString layout is incorrect" + + Example: + >>> resolver.resolve({"Fn::ToJsonString": {"key": "value"}}) + '{"key":"value"}' + >>> resolver.resolve({"Fn::ToJsonString": [1, 2, 3]}) + '[1,2,3]' + >>> resolver.resolve({"Fn::ToJsonString": "not-valid"}) + InvalidTemplateException: Fn::ToJsonString layout is incorrect + """ + # Extract the arguments from the intrinsic function + args = self.get_function_args(value) + + # Validate that the input is a dictionary or list before resolution + # This catches cases where the input is a primitive type + if not isinstance(args, (dict, list)): + raise InvalidTemplateException("Fn::ToJsonString layout is incorrect") + + # Check for unsupported intrinsics BEFORE resolution + # This matches Kotlin behavior where Fn::And etc. are rejected before being resolved + self._check_for_unsupported_intrinsics(args) + + # Resolve any nested intrinsic functions where possible + # The parent resolver will preserve unresolvable intrinsics (like Fn::GetAtt) + # in partial resolution mode + if self.parent is not None: + resolved_args = self.parent.resolve_value(args) + else: + # If no parent resolver, use args as-is (for testing) + resolved_args = args + + # Check for unresolved intrinsics that return non-String values + # These cannot be serialized to JSON properly + self._check_for_unresolved_non_string_intrinsics(resolved_args) + + # After resolution, validate the result is still a dictionary or list + # This handles cases where a nested intrinsic resolves to a non-dict/list + if not isinstance(resolved_args, (dict, list)): + raise InvalidTemplateException("Fn::ToJsonString layout is incorrect") + + # Convert to JSON string with compact separators (no extra whitespace) + # This matches the CloudFormation behavior for Fn::ToJsonString + return json.dumps(resolved_args, separators=(",", ":")) + + def _check_for_unsupported_intrinsics(self, value: Any) -> None: + """ + Check for intrinsic functions that are not supported in Fn::ToJsonString. + + This check is performed BEFORE resolution to match Kotlin behavior. + + Args: + value: The value to check. + + Raises: + InvalidTemplateException: If an unsupported intrinsic is found. + """ + if isinstance(value, dict): + if len(value) == 1: + key = next(iter(value.keys())) + inner_value = value[key] + + # Check for AWS::NotificationARNs pseudo-parameter + if key == "Ref" and inner_value == "AWS::NotificationARNs": + raise InvalidTemplateException( + "Fn::ToJsonString does not support AWS::NotificationARNs pseudo parameter" + ) + + # Condition intrinsics are not supported in Fn::ToJsonString + condition_intrinsics = {"Fn::And", "Fn::Or", "Fn::Not", "Fn::Equals"} + if key in condition_intrinsics: + raise InvalidTemplateException(f"Fn::ToJsonString does not support {key} intrinsic function") + # Recursively check nested values + for v in value.values(): + self._check_for_unsupported_intrinsics(v) + elif isinstance(value, list): + for item in value: + self._check_for_unsupported_intrinsics(item) + + def _check_for_unresolved_non_string_intrinsics(self, value: Any) -> None: + """ + Check for unresolved intrinsic functions that return non-String values. + + Fn::ToJsonString cannot properly serialize unresolved intrinsics that + return non-String values (like Fn::Split which returns a list). + + This check is performed AFTER resolution. + + Args: + value: The value to check. + + Raises: + InvalidTemplateException: If an unsupported intrinsic is found. + """ + if isinstance(value, dict): + if len(value) == 1: + key = next(iter(value.keys())) + inner_value = value[key] + + # Intrinsics that return non-String values and must be resolved + # Note: Fn::Cidr and Fn::GetAZs are allowed because they can be + # wrapped in Fn::Select which returns a single string + non_string_intrinsics = { + "Fn::Split", # Returns list, must be resolved + } + # Only reject if it looks like a valid intrinsic (has a list argument) + if key in non_string_intrinsics and isinstance(inner_value, list): + raise InvalidTemplateException(f"Unable to resolve {key} intrinsic function") + # Recursively check nested values + for v in value.values(): + self._check_for_unresolved_non_string_intrinsics(v) + elif isinstance(value, list): + for item in value: + self._check_for_unresolved_non_string_intrinsics(item) diff --git a/samcli/lib/cfn_language_extensions/serialization.py b/samcli/lib/cfn_language_extensions/serialization.py new file mode 100644 index 00000000000..33e0e8b232d --- /dev/null +++ b/samcli/lib/cfn_language_extensions/serialization.py @@ -0,0 +1,142 @@ +""" +Template serialization functions for CloudFormation Language Extensions. + +This module provides functions to serialize processed CloudFormation templates +to JSON and YAML formats. The serialization functions produce valid output +that can be parsed by standard JSON and YAML parsers. + +Requirements: + - 13.1: THE Package SHALL provide a function to serialize processed templates to JSON format + - 13.2: THE Package SHALL provide a function to serialize processed templates to YAML format + - 13.3: JSON serialization SHALL produce valid JSON that can be parsed back + - 13.4: YAML serialization SHALL produce valid YAML that can be parsed back +""" + +import json +from typing import Any, Dict, Optional + +import yaml + + +def serialize_to_json( + template: Dict[str, Any], + indent: Optional[int] = 2, + sort_keys: bool = False, +) -> str: + """ + Serialize a processed CloudFormation template to JSON format. + + This function produces valid JSON that can be parsed by standard JSON parsers. + The output is suitable for use with CloudFormation or other tools that + consume JSON templates. + + Args: + template: The processed CloudFormation template dictionary. + indent: Number of spaces for indentation. Use None for compact output. + Defaults to 2 for readable output. + sort_keys: Whether to sort dictionary keys alphabetically. + Defaults to False to preserve original key order. + + Returns: + A JSON string representation of the template. + + Raises: + TypeError: If the template contains non-JSON-serializable values. + + Example: + >>> template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + >>> json_str = serialize_to_json(template) + >>> print(json_str) + { + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue" + } + } + } + + Requirements: + - 13.1: Provide function to serialize to JSON format + - 13.3: Produce valid JSON that can be parsed back + """ + return json.dumps(template, indent=indent, sort_keys=sort_keys, ensure_ascii=False) + + +class CloudFormationDumper(yaml.SafeDumper): + """ + Custom YAML dumper for CloudFormation templates. + + This dumper handles CloudFormation-specific YAML features: + - Preserves multi-line strings using literal block scalar style (|) + - Handles CloudFormation intrinsic function tags if present + - Produces clean, readable YAML output + """ + + pass + + +def _str_representer(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode: + """ + Custom string representer that uses literal block style for multi-line strings. + + This makes the YAML output more readable for strings containing newlines, + which is common in CloudFormation templates (e.g., inline code, policies). + """ + if "\n" in data: + # Use literal block scalar style for multi-line strings + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +# Register the custom string representer +CloudFormationDumper.add_representer(str, _str_representer) + + +def serialize_to_yaml( + template: Dict[str, Any], + default_flow_style: bool = False, + sort_keys: bool = False, + width: int = 80, +) -> str: + """ + Serialize a processed CloudFormation template to YAML format. + + This function produces valid YAML that can be parsed by standard YAML parsers. + The output is suitable for use with CloudFormation or other tools that + consume YAML templates. + + The function handles CloudFormation-specific YAML features: + - Multi-line strings are formatted using literal block scalar style (|) + - Output is formatted for readability with proper indentation + + Args: + template: The processed CloudFormation template dictionary. + default_flow_style: If True, use flow style (inline) for collections. + If False (default), use block style for readability. + sort_keys: Whether to sort dictionary keys alphabetically. + Defaults to False to preserve original key order. + width: Maximum line width before wrapping. Defaults to 80. + + Returns: + A YAML string representation of the template. + + Example: + >>> template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + >>> yaml_str = serialize_to_yaml(template) + >>> print(yaml_str) + Resources: + MyQueue: + Type: AWS::SQS::Queue + + Requirements: + - 13.2: Provide function to serialize to YAML format + - 13.4: Produce valid YAML that can be parsed back + """ + return yaml.dump( + template, + Dumper=CloudFormationDumper, + default_flow_style=default_flow_style, + sort_keys=sort_keys, + width=width, + allow_unicode=True, + ) diff --git a/samcli/lib/cfn_language_extensions/utils.py b/samcli/lib/cfn_language_extensions/utils.py new file mode 100644 index 00000000000..f37a077911b --- /dev/null +++ b/samcli/lib/cfn_language_extensions/utils.py @@ -0,0 +1,39 @@ +""" +Utility functions for CloudFormation Language Extensions. + +This module provides shared helpers used across the SAM CLI codebase +for working with templates that may contain Fn::ForEach blocks. +""" + +from typing import Dict, Iterator, Tuple + +FOREACH_PREFIX = "Fn::ForEach::" + + +def is_foreach_key(key: str) -> bool: + """Check if a resource key is a Fn::ForEach block.""" + return isinstance(key, str) and key.startswith(FOREACH_PREFIX) + + +def iter_resources(template_dict: Dict) -> Iterator[Tuple[str, Dict]]: + """ + Yield (logical_id, resource_dict) pairs from a template's Resources section, + skipping Fn::ForEach blocks and non-dict entries. + + This helper eliminates the repeated guard clause pattern: + if key.startswith("Fn::ForEach::") or not isinstance(value, dict): + continue + + Parameters + ---------- + template_dict : dict + A CloudFormation template dictionary (must have a "Resources" key). + + Yields + ------ + Tuple[str, dict] + (logical_id, resource_dict) for each regular resource. + """ + for key, value in template_dict.get("Resources", {}).items(): + if not is_foreach_key(key) and isinstance(value, dict): + yield key, value diff --git a/tests/unit/lib/cfn_language_extensions/__init__.py b/tests/unit/lib/cfn_language_extensions/__init__.py new file mode 100644 index 00000000000..8c1d2e79d5e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/__init__.py @@ -0,0 +1 @@ +"""Tests for cfn-language-extensions package.""" diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/__init__.py b/tests/unit/lib/cfn_language_extensions/compatibility/__init__.py new file mode 100644 index 00000000000..1cd95825818 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/__init__.py @@ -0,0 +1 @@ +# Compatibility tests for Kotlin implementation diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/conditionWithNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/conditionWithNull.json new file mode 100644 index 00000000000..d37f36340ff --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/conditionWithNull.json @@ -0,0 +1,24 @@ +{ + "Conditions": { + "equals": { + "Fn::Equals": [ + "prod", + "prod" + ] + } + }, + "Resources": { + "cluster": { + "Condition": null, + "DeletionPolicy": "Delete", + "UpdateReplacePolicy": null, + "Metadata": null, + "Transform": null, + "DependsOn": null, + "CreationPolicy": null, + "UpdatePolicy": null, + "Type": "AWS::ECS::Cluster", + "Properties": null + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapSelectDefaultValueList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapSelectDefaultValueList.json new file mode 100644 index 00000000000..8d19e0d6a15 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapSelectDefaultValueList.json @@ -0,0 +1,54 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "MyQueue1": { + "Type": "String", + "Default": "queue1" + }, + "MyQueue2": { + "Type": "String", + "Default": "queue2" + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "HVM64" : "ami-0ff8a91507f77f867", "HVMG2" : "ami-0a584ac55a7631c0c" + }, + "us-west-1" : { + "HVM64" : "ami-0bdb828fd58c52235", "HVMG2" : "ami-066ee5fd4a9ef77f1" + }, + "eu-west-1" : { + "HVM64" : "ami-047bb4163c506cd98", "HVMG2" : "ami-0a7c483d527806435" + }, + "ap-southeast-1" : { + "HVM64" : "ami-08569b978cc4dfa10", "HVMG2" : "ami-0be9df32ae9f92309" + }, + "ap-northeast-1" : { + "HVM64" : "ami-06cd52961ce9f0d85", "HVMG2" : "ami-053cdd503598e4a9d" + } + } + }, + "Resources": { + "myQueuePolicy" : { + "Type" : "AWS::SQS::QueuePolicy", + "Properties" : { + "Queues": [ + { + "Fn::Select" : [ + "0", + { + "Fn::FindInMap" : [ + "RegionMap", + "us-east-1", + "not-found", + {"DefaultValue": [{"Ref" : "AWS::StackName"}, {"Ref" : "MyQueue1"}, {"Ref" : "MyQueue2"}]} + ] + } + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValue.json new file mode 100644 index 00000000000..de1fd85ef40 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValue.json @@ -0,0 +1,170 @@ +{ + "Mappings": { + "MapName": { + "TopKey1": { + "SecondKey1": "Value1", + "SecondKey2": "Value2" + }, + "TopKey2": { + "SecondKey1": "Value3", + "SecondKey2": "Value4" + } + } + }, + "Resources": { + "Cluster0": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey1", + "SecondKey1", + { + "DefaultValue": "Cluster0" + } + ] + } + } + }, + "Cluster1": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey1", + "SecondKey3", + { + "DefaultValue": "Cluster1" + } + ] + } + } + }, + "Cluster2": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey3", + "SecondKey1", + { + "DefaultValue": "Cluster2" + } + ] + } + } + }, + "Cluster3": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey3", + "SecondKey3", + { + "DefaultValue": "Cluster3" + } + ] + } + } + }, + "Mesh0": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::Select": [0, {"Fn::Split": [".", "TopKey1.SecondKey1"]}]}, + {"Fn::Select": [1, {"Fn::Split": [".", "TopKey1.SecondKey1"]}]}, + {"DefaultValue": "Mesh0"} + ] + } + } + }, + "Mesh1": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + { + "Fn::Select": [ + 0, + [ + "TopKey1", + "TopKey2" + ] + ] + }, + "SecondKey1", + { + "DefaultValue": "Mesh1" + } + ] + } + } + }, + "Mesh2": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + { + "Fn::ToJsonString": { + "To": "Json", + "String": "Function" + } + }, + "SecondKey1", + { + "DefaultValue": "Mesh2" + } + ] + } + } + }, + "Mesh3": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + "TopKey1", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + ".", + "V3N.OH1" + ] + } + ] + }, + { + "DefaultValue": "Mesh3" + } + ] + } + } + }, + "Mesh4": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::Select": [0, {"Fn::Split": [".", "V3N.0H1"]}]}, + {"Fn::Select": [1, {"Fn::Split": [".", "V3N.0H1"]}]}, + {"DefaultValue": "Mesh4"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueListAsIs.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueListAsIs.json new file mode 100644 index 00000000000..99e9bd85606 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueListAsIs.json @@ -0,0 +1,52 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "MyQueue1": { + "Type": "String", + "Default": "queue1" + }, + "MyQueue2": { + "Type": "String", + "Default": "queue2" + }, + "MyQueue3": { + "Type": "String", + "Default": "queue3" + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "HVM64" : "ami-0ff8a91507f77f867", "HVMG2" : "ami-0a584ac55a7631c0c" + }, + "us-west-1" : { + "HVM64" : "ami-0bdb828fd58c52235", "HVMG2" : "ami-066ee5fd4a9ef77f1" + }, + "eu-west-1" : { + "HVM64" : "ami-047bb4163c506cd98", "HVMG2" : "ami-0a7c483d527806435" + }, + "ap-southeast-1" : { + "HVM64" : "ami-08569b978cc4dfa10", "HVMG2" : "ami-0be9df32ae9f92309" + }, + "ap-northeast-1" : { + "HVM64" : "ami-06cd52961ce9f0d85", "HVMG2" : "ami-053cdd503598e4a9d" + } + } + }, + + "Resources" : { + "myQueuePolicy" : { + "Type" : "AWS::SQS::QueuePolicy", + "Properties" : { + "Queues" : { + "Fn::FindInMap" : [ + "RegionMap", + "us-east-1", + "not-found", + {"DefaultValue": [{"Ref" : "MyQueue1"}, {"Ref" : "AWS::StackId"}, {"Ref" : "AWS::StackName"}]} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString.json new file mode 100644 index 00000000000..5857eee8492 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString.json @@ -0,0 +1,24 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "SecondKey": "Value" + } + } + }, + "Resources": { + "Cluster0": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::Split": [".", "1.2.3.4"]}, + "SecondKey", + {"DefaultValue": "default-cluster"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnGetAttInDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnGetAttInDefaultValue.json new file mode 100644 index 00000000000..ab85d7a3d7e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnGetAttInDefaultValue.json @@ -0,0 +1,30 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "Queue": "Value" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "Queue" + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey", + "NoKey", + {"DefaultValue": {"Fn::GetAtt": ["Queue", "QueueName"]}} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnRefInDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnRefInDefaultValue.json new file mode 100644 index 00000000000..f9dd6c098fe --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithFnRefInDefaultValue.json @@ -0,0 +1,30 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "Queue": "Value" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "Queue" + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey", + "NoKey", + {"DefaultValue": {"Ref": "Queue"}} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithIntrinsic.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithIntrinsic.json new file mode 100644 index 00000000000..320c92074a6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithIntrinsic.json @@ -0,0 +1,112 @@ +{ + "Parameters": { + "Stage": { + "Type": "String", + "Default": "Prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ + "Prod", + {"Ref": "Stage"} + ] + } + }, + "Mappings": { + "MapName": { + "TopKey1": { + "SecondKey1": "Value1", + "SecondKey2": "Value2", + "{\"To\":\"Json\",\"String\":\"Function\"}": "Value3" + }, + "TopKey2": { + "SecondKey1": "Value4", + "SecondKey2": "Value5" + } + } + }, + "Resources": { + "Queue0": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySecond": 4 + } + }, + "Mesh": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::Sub": ["${Prefix}${Suffix}", {"Suffix": "Key1", "Prefix": "Top"}]}, + {"Fn::Sub": ["${Prefix}${Suffix}", {"Prefix": "Second", "Suffix": "Key1"}]} + ] + } + } + }, + "Mesh2": { + "Type": "AWS::AppMesh::Mesh", + "Properties": { + "MeshName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::If": ["IsProd", "TopKey1", "TopKey2"]}, + {"Fn::If": ["IsProd", "SecondKey1", "SecondKey2"]} + ] + } + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + {"Fn::Join": ["", ["Top", "Key1"]]}, + {"Fn::Join": ["", ["Second", "Key1"]]} + ] + } + } + }, + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Fn::FindInMap": [ + {"Fn::Select": [0, {"Fn::Split": [".", "MapName.TopKey1.SecondKey1"]}]}, + {"Fn::Select": [1, {"Fn::Split": [".", "MapName.TopKey1.SecondKey1"]}]}, + {"Fn::Select": [2, {"Fn::Split": [".", "MapName.TopKey1.SecondKey1"]}]} + ] + } + } + }, + "Cluster2": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + {"Fn::Select": [{"Fn::Length": []}, {"Fn::Split": [".", "MapName.TopKey1.SecondKey2"]}]}, + {"Fn::Select": [{"Fn::Length": ["1"]}, {"Fn::Split": [".", "MapName.TopKey1.SecondKey2"]}]}, + {"Fn::Select": [{"Fn::Length": ["1", "2"]}, {"Fn::Split": [".", "MapName.TopKey1.SecondKey2"]}]} + ] + } + } + }, + "Cluster3": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey1", + {"Fn::ToJsonString": { + "To": "Json", + "String": "Function" + }} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithMapNameNotResolveToString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithMapNameNotResolveToString.json new file mode 100644 index 00000000000..3112f341aa9 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithMapNameNotResolveToString.json @@ -0,0 +1,23 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "SecondKey": "Value" + } + } + }, + "Resources": { + "Cluster0": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + {"Fn::Split": [".", "1.2.3.4"]}, + "TopKey", + "SecondKey" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithReferenceToIncorrectParameterType.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithReferenceToIncorrectParameterType.json new file mode 100644 index 00000000000..83a86df9aee --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithReferenceToIncorrectParameterType.json @@ -0,0 +1,29 @@ +{ + "Parameters": { + "cdl": { + "Type": "CommaDelimitedList", + "Default": "1,2,3" + } + }, + "Mappings": { + "MapName": { + "TopKey": { + "SecondKey": "Value" + } + } + }, + "Resources": { + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + {"Ref": "cdl"}, + "SecondKey" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnGetAtt.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnGetAtt.json new file mode 100644 index 00000000000..6f7bf4c1718 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnGetAtt.json @@ -0,0 +1,35 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "Queue": "Value" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "Queue" + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey", + { + "Fn::GetAtt": [ + "Queue", + "QueueName" + ] + }, + {"DefaultValue": "Cluster0"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnRef.json new file mode 100644 index 00000000000..1ab7cc6a40a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnRef.json @@ -0,0 +1,30 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "Queue": "Value" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "Queue" + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + "TopKey", + {"Ref": "Queue"}, + {"DefaultValue": "Cluster0"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnSub.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnSub.json new file mode 100644 index 00000000000..a04efd65ff8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionFnSub.json @@ -0,0 +1,42 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "Queue": "Value" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "Queue" + } + }, + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + "MapName", + { + "Fn::Sub": [ + "${QueueName}", + { + "QueueName": { + "Fn::GetAtt": [ + "Queue", + "QueueName" + ] + } + } + ] + }, + "Queue", + {"DefaultValue": "Cluster0"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionInMapName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionInMapName.json new file mode 100644 index 00000000000..4e6498b832a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnFindInMapWithUnsupportedFunctionInMapName.json @@ -0,0 +1,30 @@ +{ + "Mappings": { + "MapName": { + "TopKey": { + "SecondKey": "Value" + } + } + }, + "Resources": { + "Cluster0": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "MapName" + } + }, + "Cluster1": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::FindInMap": [ + {"Fn::GetAtt": ["Cluster0", "ClusterName"]}, + "TopKey", + "SecondKey", + {"DefaultValue": "cluster-default"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfOutputBug.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfOutputBug.json new file mode 100644 index 00000000000..5e14d9a6e53 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfOutputBug.json @@ -0,0 +1,140 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "cidr": { + "Type": "String", + "Default": "10.0.0.0/16" + }, + "noOfAZ": { + "Type": "Number", + "MinValue": 1, + "MaxValue": 2, + "Default": 1 + } + }, + "Conditions": { + "noOfAZ1": { + "Fn::Equals": [ + { + "Ref": "noOfAZ" + }, + 1 + ] + }, + "noOfAZ2": { + "Fn::Equals": [ + { + "Ref": "noOfAZ" + }, + 2 + ] + }, + "azA": { + "Fn::Or": [ + { + "Condition": "noOfAZ1" + }, + { + "Condition": "azB" + } + ] + }, + "azB": { + "Condition": "noOfAZ2" + } + }, + "Resources": { + "vpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": { + "Ref": "cidr" + }, + "EnableDnsHostnames": true + } + }, + "publicSubnetA": { + "Condition": "azA", + "Type": "AWS::EC2::Subnet", + "Properties": { + "VpcId": { + "Ref": "vpc" + }, + "CidrBlock": { + "Fn::Select": [ + 0, + { + "Fn::Cidr": [ + { + "Ref": "cidr" + }, + 16, + 8 + ] + } + ] + }, + "AvailabilityZone": { + "Fn::Select": [ + 0, + { + "Fn::GetAZs": { + "Ref": "AWS::Region" + } + } + ] + }, + "MapPublicIpOnLaunch": true + } + }, + "publicSubnetB": { + "Condition": "azB", + "Type": "AWS::EC2::Subnet", + "Properties": { + "VpcId": { + "Ref": "vpc" + }, + "CidrBlock": { + "Fn::Select": [ + 1, + { + "Fn::Cidr": [ + { + "Ref": "cidr" + }, + 16, + 8 + ] + } + ] + }, + "AvailabilityZone": { + "Fn::Select": [ + 1, + { + "Fn::GetAZs": { + "Ref": "AWS::Region" + } + } + ] + }, + "MapPublicIpOnLaunch": true + } + } + }, + "Outputs": { + "publicSubnets": { + "Value": { + "Fn::If": [ + "azA", + { + "Ref": "publicSubnetA" + }, + { + "Ref": "AWS::NoValue" + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfReferencingNonExistingCondition.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfReferencingNonExistingCondition.json new file mode 100644 index 00000000000..b71767f6380 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfReferencingNonExistingCondition.json @@ -0,0 +1,29 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "Stage": { + "Type": "String", + "Default": "gamma" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Fn::If": [ + "NonExistingCondition", + "A", + "B" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfWithUnresolvableInFalseBranch.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfWithUnresolvableInFalseBranch.json new file mode 100644 index 00000000000..efdf6fade94 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnIfWithUnresolvableInFalseBranch.json @@ -0,0 +1,142 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "env": { + "Type": "String", + "AllowedValues": [ + "DEV", + "PROD" + ], + "Default": "DEV" + }, + "subnets": { + "Type": "List", + "Default": "subnet-1" + }, + "subnetCount": { + "Type": "Number", + "MinValue": 1, + "MaxValue": 2, + "Default": 1 + } + }, + "Conditions": { + "isProd": { + "Fn::Equals": [ + { + "Ref": "env" + }, + "PROD" + ] + }, + "configureSubnet1": { + "Fn::Or": [ + { + "Fn::Equals": [ + { + "Ref": "subnetCount" + }, + 1 + ] + }, + { + "Condition": "configureSubnet2" + } + ] + }, + "configureSubnet2": { + "Fn::Equals": [ + { + "Ref": "subnetCount" + }, + 2 + ] + } + }, + "Resources": { + "executionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "test-lambda-exec-role", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "allow-manage-ec2-network-interface-policy", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:CreateNetworkInterface", + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/*" + }, + { + "Fn::If": [ + "configureSubnet1", + { + "Fn::Sub": [ + "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${subnet}", + { + "subnet": { + "Fn::Select": [ + 0, + { + "Ref": "subnets" + } + ] + } + } + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + { + "Fn::If": [ + "configureSubnet2", + { + "Fn::Sub": [ + "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${subnet}", + { + "subnet": { + "Fn::Select": [ + 1, + { + "Ref": "subnets" + } + ] + } + } + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ] + } + ] + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSplitWithEmptySpliter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSplitWithEmptySpliter.json new file mode 100644 index 00000000000..a58f33eeea8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSplitWithEmptySpliter.json @@ -0,0 +1,15 @@ +{ + "Resources": { + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": { + "Fn::Split": [ + "", + "a,b,c" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithParameter.json new file mode 100644 index 00000000000..6aba8236f62 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithParameter.json @@ -0,0 +1,22 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Parameters" : { + "name" : { + "Type" : "String", + "Default": "myname" + } + }, + "Resources" : { + "Topic" : { + "Type" : "AWS::SNS::Topic", + "Properties" : { + "TopicName" : { + "Fn::Sub": [ + "topic-${prefix}-${name}", + {"prefix": "bla"} + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithReference.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithReference.json new file mode 100644 index 00000000000..16581c69f11 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/fnSubWithReference.json @@ -0,0 +1,16 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Resources" : { + "Topic" : { + "Type" : "AWS::SNS::Topic", + "Properties" : { + "TopicName" : { + "Fn::Sub": [ + "hello-${test}", + {"Ref": "abc"} + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionHasUnresolvableValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionHasUnresolvableValue.json new file mode 100644 index 00000000000..233273e4531 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionHasUnresolvableValue.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2", {"Ref": "DoesNotExistValue"}], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionNotResolvable.json new file mode 100644 index 00000000000..c0153db98bf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionNotResolvable.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "DoesNotExistCollection"}, + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRef.json new file mode 100644 index 00000000000..a712863f8f6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRef.json @@ -0,0 +1,32 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "MyQueueList"}, + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRefExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRefExpected.json new file mode 100644 index 00000000000..fdb5021574b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachCollectionRefExpected.json @@ -0,0 +1,38 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition2": { + "Fn::Equals": [ + "1", + "2" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElements.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElements.json new file mode 100644 index 00000000000..4d328356dcc --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElements.json @@ -0,0 +1,43 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName1": [ + "VariableName1", + ["1", "2"], + { + "Condition${VariableName1}": { + "Fn::Equals": ["1", {"Ref": "VariableName1"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + }, + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElementsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElementsExpected.json new file mode 100644 index 00000000000..b0cf7b8324d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachInOrderElementsExpected.json @@ -0,0 +1,52 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + }, + "Conditions": { + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition2": { + "Fn::Equals": [ + "1", + "2" + ] + }, + "Condition3": { + "Fn::Equals": [ + "1", + "3" + ] + }, + "Condition4": { + "Fn::Equals": [ + "1", + "4" + ] + }, + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTest.json new file mode 100644 index 00000000000..a1e5dfde23c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTest.json @@ -0,0 +1,34 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": [1, "3"] + } + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + {"Fn::FindInMap": ["Buckets", "Properties", "Identifiers"]}, + [2, "4"], + [3, "5"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTestExpected.json new file mode 100644 index 00000000000..50a16986ecd --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsFindInMapTestExpected.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": [1, "3"] + } + } + }, + "Conditions": { + "Condition1": { + "Fn::Equals": ["1", "3"] + }, + "Condition2": { + "Fn::Equals": ["1", "4"] + }, + "Condition3": { + "Fn::Equals": ["1", "5"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreIdentifiersTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreIdentifiersTest.json new file mode 100644 index 00000000000..330d170f1c3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreIdentifiersTest.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + [1], + [2], + [3, "3"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreValuesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreValuesTest.json new file mode 100644 index 00000000000..804289faaa0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsMoreValuesTest.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId"], + [ + [1, "1"], + [2, "2"], + [3, "3"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "LogicalId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTest.json new file mode 100644 index 00000000000..a1ab1c3e536 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTest.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + [1, "1"], + [2, "2"], + [3, "3"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTestExpected.json new file mode 100644 index 00000000000..9ed8f84efdd --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsTestExpected.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Condition1": { + "Fn::Equals": ["1", "1"] + }, + "Condition2": { + "Fn::Equals": ["1", "2"] + }, + "Condition3": { + "Fn::Equals": ["1", "3"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithListIdentifierTest.json new file mode 100644 index 00000000000..c67655248f2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithListIdentifierTest.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", ["TopicId"]], + [ + [1, "3"], + [2, "4"], + [3, "5"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefListIdentifierTest.json new file mode 100644 index 00000000000..3984a8f4863 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefListIdentifierTest.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "AmiId,TopicId" + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", {"Ref": "MyQueueList"}], + [ + [1, "3"], + [2, "4"], + [3, "5"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTest.json new file mode 100644 index 00000000000..8c1e5b61c02 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTest.json @@ -0,0 +1,37 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,3" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + [{"Ref": "IdentifierString"}, "TopicId"], + [ + {"Ref": "ValueList"}, + [2, "4"], + [3, "5"] + ], + { + "Condition${LogicalId}": { + "Fn::Equals": ["1", {"Ref": "TopicId"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTestExpected.json new file mode 100644 index 00000000000..499dfe9eb8c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachListOfListsWithRefTestExpected.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,3" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Conditions": { + "Condition1": { + "Fn::Equals": ["1", "3"] + }, + "Condition2": { + "Fn::Equals": ["1", "4"] + }, + "Condition3": { + "Fn::Equals": ["1", "5"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachLoopNameConflictingWithLoopIdentifier.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachLoopNameConflictingWithLoopIdentifier.json new file mode 100644 index 00000000000..27561bdab0c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachLoopNameConflictingWithLoopIdentifier.json @@ -0,0 +1,51 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "TopicList": { + "Type": "CommaDelimitedList", + "Default": "TopicA,TopicB" + } + }, + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + { + "Ref": "TopicList" + }, + { + "${SameLoopName2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "My${VariableName}" + } + } + } + } + ] + }, + "Conditions": { + "Fn::ForEach::SameLoopName1": [ + "SameLoopName2", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "SameLoopName2"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + }, + "Fn::ForEach::SameLoopName2": [ + "SameLoopName1", + ["3", "4"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "SameLoopName1"}] + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultiple.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultiple.json new file mode 100644 index 00000000000..1fd69a6b992 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultiple.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName1": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleExpected.json new file mode 100644 index 00000000000..d5dd4af70cf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleExpected.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + }, + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition2": { + "Fn::Equals": [ + "1", + "2" + ] + }, + "Condition3": { + "Fn::Equals": [ + "1", + "3" + ] + }, + "Condition4": { + "Fn::Equals": [ + "1", + "4" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfter.json new file mode 100644 index 00000000000..998ed0dd2d8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfter.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Condition0": { + "Fn::Equals": ["1", "0"] + }, + "Fn::ForEach::UniqueLoopName1": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfterExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfterExpected.json new file mode 100644 index 00000000000..d5dd4af70cf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachMultipleForEachAfterExpected.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + }, + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition2": { + "Fn::Equals": [ + "1", + "2" + ] + }, + "Condition3": { + "Fn::Equals": [ + "1", + "3" + ] + }, + "Condition4": { + "Fn::Equals": [ + "1", + "4" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNested.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNested.json new file mode 100644 index 00000000000..5505ea992a1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNested.json @@ -0,0 +1,62 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoop": [ + "param1", + ["1"], + { + "Condition${param1}": { + "Fn::Equals": ["1", {"Ref": "param1"}] + }, + "Fn::ForEach::UniqueLoop2": [ + "param2", + ["2"], + { + "Condition${param1}${param2}": { + "Fn::Equals": ["1", {"Ref": "param2"}] + }, + "Fn::ForEach::UniqueLoop3": [ + "param3", + ["3"], + { + "Condition${param1}${param2}${param3}": { + "Fn::Equals": ["1", {"Ref": "param3"}] + }, + "Fn::ForEach::UniqueLoop4": [ + "param4", + ["4"], + { + "Condition${param1}${param2}${param3}${param4}": { + "Fn::Equals": ["1", {"Ref": "param4"}] + }, + "Fn::ForEach::UniqueLoop5": [ + "param5", + ["5"], + { + "Condition${param1}${param2}${param3}${param4}${param5}": { + "Fn::Equals": ["1", {"Ref": "param5"}] + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedExpected.json new file mode 100644 index 00000000000..e8efb18707b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedExpected.json @@ -0,0 +1,50 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + }, + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition12": { + "Fn::Equals": [ + "1", + "2" + ] + }, + "Condition123": { + "Fn::Equals": [ + "1", + "3" + ] + }, + "Condition1234": { + "Fn::Equals": [ + "1", + "4" + ] + }, + "Condition12345": { + "Fn::Equals": [ + "1", + "5" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingIdentifierName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingIdentifierName.json new file mode 100644 index 00000000000..6819b43df87 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingIdentifierName.json @@ -0,0 +1,62 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Condition${param1}": { + "Fn::Equals": ["1", {"Ref": "param1"}] + }, + "Fn::ForEach::UniqueLoop2": [ + "param2", + ["2"], + { + "Condition${param1}${param2}": { + "Fn::Equals": ["1", {"Ref": "param2"}] + }, + "Fn::ForEach::UniqueLoop3": [ + "param3", + ["3"], + { + "Condition${param1}${param2}${param3}": { + "Fn::Equals": ["1", {"Ref": "param3"}] + }, + "Fn::ForEach::UniqueLoop4": [ + "param1", + ["4"], + { + "Condition${param1}${param2}${param3}${param4}": { + "Fn::Equals": ["1", {"Ref": "param4"}] + }, + "Fn::ForEach::UniqueLoop5": [ + "param5", + ["5"], + { + "Condition${param1}${param2}${param3}${param4}${param5}": { + "Fn::Equals": ["1", {"Ref": "param5"}] + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingLoopName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingLoopName.json new file mode 100644 index 00000000000..44f8416bc2f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachNestedWithConflictingLoopName.json @@ -0,0 +1,62 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Condition${param1}": { + "Fn::Equals": ["1", {"Ref": "param1"}] + }, + "Fn::ForEach::UniqueLoop2": [ + "param2", + ["2"], + { + "Condition${param1}${param2}": { + "Fn::Equals": ["1", {"Ref": "param2"}] + }, + "Fn::ForEach::UniqueLoop3": [ + "param3", + ["3"], + { + "Condition${param1}${param2}${param3}": { + "Fn::Equals": ["1", {"Ref": "param3"}] + }, + "Fn::ForEach::UniqueLoop4": [ + "param4", + ["4"], + { + "Condition${param1}${param2}${param3}${param4}": { + "Fn::Equals": ["1", {"Ref": "param4"}] + }, + "Fn::ForEach::UniqueLoopName": [ + "param5", + ["5"], + { + "Condition${param1}${param2}${param3}${param4}${param5}": { + "Fn::Equals": ["1", {"Ref": "param5"}] + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesInvalidItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesInvalidItemTest.json new file mode 100644 index 00000000000..b60a87ab7c7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesInvalidItemTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + "key-with-dashes" + ], + { + "Instance${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTest.json new file mode 100644 index 00000000000..5dc6ae8b0e6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTest.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1.1 + ], + { + "Condition": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTestExpected.json new file mode 100644 index 00000000000..f2f2bd96def --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesOneItemTestExpected.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Condition": { + "Fn::Equals": [ + "1", + "1.1" + ] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTest.json new file mode 100644 index 00000000000..539a6c69603 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + true + ], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTestExpected.json new file mode 100644 index 00000000000..9576db3d4bd --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachPrimitiveTypesTestExpected.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Conditiontrue": { + "Fn::Equals": [ + "1", + "true" + ] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingle.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingle.json new file mode 100644 index 00000000000..d98bd4ccb0a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingle.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingleExpected.json new file mode 100644 index 00000000000..f3c83141192 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachSingleExpected.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + }, + "Conditions": { + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + }, + "Condition2": { + "Fn::Equals": [ + "1", + "2" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueConflictsWithParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueConflictsWithParameter.json new file mode 100644 index 00000000000..24b6afab370 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueConflictsWithParameter.json @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "SameParam": { + "Type": "String", + "Default": "SameParam" + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "SameParam"}, + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotResolvable.json new file mode 100644 index 00000000000..d423d7a520b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotResolvable.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "UndefinedValue"}, + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsed.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsed.json new file mode 100644 index 00000000000..76d57961401 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsed.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Condition1": { + "Fn::Equals": ["1", "1"] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsedExpected.json new file mode 100644 index 00000000000..8907685201b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueNotUsedExpected.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueResolvesToNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueResolvesToNull.json new file mode 100644 index 00000000000..b69702608d0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachValueResolvesToNull.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "AWS::NoValue"}, + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyList.json new file mode 100644 index 00000000000..cb42d61e321 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyList.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyListExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyListExpected.json new file mode 100644 index 00000000000..d9f274b0c9e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsEmptyListExpected.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStrings.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStrings.json new file mode 100644 index 00000000000..47ae3aa524e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStrings.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [""], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStringsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStringsExpected.json new file mode 100644 index 00000000000..da8b2b2e85e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionAsListOfEmptyStringsExpected.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Condition": { + "Fn::Equals": ["1", ""] + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionWithNumericValues.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionWithNumericValues.json new file mode 100644 index 00000000000..eadd2fb76ca --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithCollectionWithNumericValues.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [1, 2], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameLogicalId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameLogicalId.json new file mode 100644 index 00000000000..679107d8e79 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameLogicalId.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::SameName": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "SameName": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameParam.json new file mode 100644 index 00000000000..d025275469f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithConflictingLoopNameParam.json @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Conditions": { + "Fn::ForEach::ParamName": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsNull.json new file mode 100644 index 00000000000..91352efb8cc --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsNull.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + null, + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsString.json new file mode 100644 index 00000000000..c647e2052f7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidCollectionAsString.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + "123", + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidFragment.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidFragment.json new file mode 100644 index 00000000000..7ab135286a2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidFragment.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Key": "Value" + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsEmptyString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsEmptyString.json new file mode 100644 index 00000000000..eee8bc33e22 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsEmptyString.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "", + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsList.json new file mode 100644 index 00000000000..a808def2b8b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsList.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + ["VariableName"], + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNull.json new file mode 100644 index 00000000000..ab34a60bb20 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNull.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + null, + ["1", "2"], + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNumeric.json new file mode 100644 index 00000000000..fc5f9df153d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidIdentifierAsNumeric.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + 1, + ["1", "2"], + { + "Condition${1}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidLayout.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidLayout.json new file mode 100644 index 00000000000..5916dd5de27 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithInvalidLayout.json @@ -0,0 +1,14 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [] + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoEchoParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoEchoParameter.json new file mode 100644 index 00000000000..cc2bd64dd93 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoEchoParameter.json @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "NoEchoList": { + "Type": "CommaDelimitedList", + "NoEcho": true + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "NoEchoList"}, + { + "Condition${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json new file mode 100644 index 00000000000..15d23b3afde --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "SameName": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValid.json new file mode 100644 index 00000000000..cc64d59e39d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValid.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Condition1": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json new file mode 100644 index 00000000000..0e9bfa8dafb --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Conditions": { + "Condition0": { + "Fn::Equals": [ + "1", + "0" + ] + }, + "Condition1": { + "Fn::Equals": [ + "1", + "1" + ] + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyExistingInParentLevel.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyExistingInParentLevel.json new file mode 100644 index 00000000000..9f3031c02b4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyExistingInParentLevel.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "SameName${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "SameName1": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonAlphaNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonAlphaNumeric.json new file mode 100644 index 00000000000..2ff19f1a317 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonAlphaNumeric.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "key-with-dash"], + { + "Prefix${VariableName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonResolvable.json new file mode 100644 index 00000000000..67b14ce04f1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyNonResolvable.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}${UndefinedValue}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyWithParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyWithParam.json new file mode 100644 index 00000000000..d08a75dc5cf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/conditions/fnForEachWithOutputKeyWithParam.json @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Conditions": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Condition${VariableName}${ParamName}": { + "Fn::Equals": ["1", {"Ref": "VariableName"}] + } + } + ], + "Condition0": { + "Fn::Equals": ["1", "0"] + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionHasUnresolvableValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionHasUnresolvableValue.json new file mode 100644 index 00000000000..463492b4d64 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionHasUnresolvableValue.json @@ -0,0 +1,39 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2", {"Ref": "DoesNotExistValue"}], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionNotResolvable.json new file mode 100644 index 00000000000..af0593bf3a0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionNotResolvable.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "DoesNotExistCollection"}, + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRef.json new file mode 100644 index 00000000000..fead67af2f4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRef.json @@ -0,0 +1,42 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "MyQueueList"}, + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRefExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRefExpected.json new file mode 100644 index 00000000000..c5121ff809e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachCollectionRefExpected.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic2" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElements.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElements.json new file mode 100644 index 00000000000..4b83d7ad5d5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElements.json @@ -0,0 +1,46 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + }, + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElementsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElementsExpected.json new file mode 100644 index 00000000000..12240e50a41 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachInOrderElementsExpected.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": { + "Ref": "Topic0" + } + }, + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic2" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic3" + }, + "Output4": { + "Description": "MyDescription", + "Value": "Topic4" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTest.json new file mode 100644 index 00000000000..651cdca218d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTest.json @@ -0,0 +1,47 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": [1, 0] + } + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + {"Fn::FindInMap": ["Buckets", "Properties", "Identifiers"]}, + [2, 1], + [3, 2] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTestExpected.json new file mode 100644 index 00000000000..5fb47eabdaf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsFindInMapTestExpected.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": [1, 0] + } + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic0" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic2" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreIdentifiersTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreIdentifiersTest.json new file mode 100644 index 00000000000..42c7564ca1c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreIdentifiersTest.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + [0], + [1], + [2, "ami-id3"] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreValuesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreValuesTest.json new file mode 100644 index 00000000000..f332f78a6d6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsMoreValuesTest.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId"], + [ + [0, "ami-id1"], + [1, "ami-id2"], + [2, "ami-id3"] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${LogicalId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTest.json new file mode 100644 index 00000000000..8e43f11c6a9 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTest.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", "TopicId"], + [ + [1, 0], + [2, 1], + [3, 2] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTestExpected.json new file mode 100644 index 00000000000..341cb68b3f5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsTestExpected.json @@ -0,0 +1,38 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic0" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic2" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithListIdentifierTest.json new file mode 100644 index 00000000000..26cbf23b2f3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithListIdentifierTest.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", ["TopicId"]], + [ + [1, 0], + [2, 1], + [3, 2] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefListIdentifierTest.json new file mode 100644 index 00000000000..2f808ae7c5e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefListIdentifierTest.json @@ -0,0 +1,46 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "AmiId,TopicId" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["LogicalId", {"Ref": "MyQueueList"}], + [ + [1, 0], + [2, 1], + [3, 2] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTest.json new file mode 100644 index 00000000000..c1f56c6d6c2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTest.json @@ -0,0 +1,50 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,0" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + [{"Ref": "IdentifierString"}, "TopicId"], + [ + {"Ref": "ValueList"}, + [2, 1], + [3, 2] + ], + { + "Output${LogicalId}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${TopicId}"} + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTestExpected.json new file mode 100644 index 00000000000..c7101081748 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachListOfListsWithRefTestExpected.json @@ -0,0 +1,48 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,0" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic0" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic2" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachLoopNameConflictingWithLoopIdentifier.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachLoopNameConflictingWithLoopIdentifier.json new file mode 100644 index 00000000000..91bb8c74822 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachLoopNameConflictingWithLoopIdentifier.json @@ -0,0 +1,50 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "TopicList": { + "Type": "CommaDelimitedList", + "Default": "TopicA,TopicB" + } + }, + "Resources": { + "Fn::ForEach::SameLoopName1": [ + "SameLoopName2", + { + "Ref": "TopicList" + }, + { + "${SameLoopName2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "My${SameLoopName2}" + } + } + } + } + ] + }, + "Outputs": { + "Fn::ForEach::SameLoopName1": [ + "SameLoopName2", + ["1", "2"], + { + "Output${SameLoopName2}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${SameLoopName2}"} + } + } + ], + "Fn::ForEach::SameLoopName2": [ + "SameLoopName1", + ["3", "4"], + { + "Output${SameLoopName1}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${SameLoopName1}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultiple.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultiple.json new file mode 100644 index 00000000000..b72462a3335 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultiple.json @@ -0,0 +1,62 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleExpected.json new file mode 100644 index 00000000000..3581ab2f4d0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleExpected.json @@ -0,0 +1,60 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": { + "Ref": "Topic0" + } + }, + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic2" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic3" + }, + "Output4": { + "Description": "MyDescription", + "Value": "Topic4" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfter.json new file mode 100644 index 00000000000..079125b3d41 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfter.json @@ -0,0 +1,62 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + }, + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfterExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfterExpected.json new file mode 100644 index 00000000000..3581ab2f4d0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachMultipleForEachAfterExpected.json @@ -0,0 +1,60 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": { + "Ref": "Topic0" + } + }, + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic2" + }, + "Output3": { + "Description": "MyDescription", + "Value": "Topic3" + }, + "Output4": { + "Description": "MyDescription", + "Value": "Topic4" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNested.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNested.json new file mode 100644 index 00000000000..98a323b54ab --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNested.json @@ -0,0 +1,68 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Output${param1}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}"} + }, + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Output${param1}${param2}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}"} + }, + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Output${param1}${param2}${param3}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}"} + }, + "Fn::ForEach::UniqueLoopName4": [ + "param4", + ["B"], + { + "Output${param1}${param2}${param3}${param4}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}"} + }, + "Fn::ForEach::UniqueLoopName5": [ + "param5", + ["3"], + { + "Output${param1}${param2}${param3}${param4}${param5}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}${param5}"} + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedExpected.json new file mode 100644 index 00000000000..fea10a9ed5d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedExpected.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Output0": { + "Description": "MyDescription", + "Value": { + "Ref": "Topic0" + } + }, + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output1A": { + "Description": "MyDescription", + "Value": "Topic1A" + }, + "Output1A2": { + "Description": "MyDescription", + "Value": "Topic1A2" + }, + "Output1A2B": { + "Description": "MyDescription", + "Value": "Topic1A2B" + }, + "Output1A2B3": { + "Description": "MyDescription", + "Value": "Topic1A2B3" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingIdentifierName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingIdentifierName.json new file mode 100644 index 00000000000..b4188e61d3d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingIdentifierName.json @@ -0,0 +1,68 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Output${param1}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}"} + }, + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Output${param1}${param2}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}"} + }, + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Output${param1}${param2}${param3}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}"} + }, + "Fn::ForEach::UniqueLoopName4": [ + "param1", + ["B"], + { + "Output${param1}${param2}${param3}${param4}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}"} + }, + "Fn::ForEach::UniqueLoopName5": [ + "param5", + ["3"], + { + "Output${param1}${param2}${param3}${param4}${param5}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}${param5}"} + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingLoopName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingLoopName.json new file mode 100644 index 00000000000..ab57e808b22 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachNestedWithConflictingLoopName.json @@ -0,0 +1,68 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Output${param1}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}"} + }, + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Output${param1}${param2}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}"} + }, + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Output${param1}${param2}${param3}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}"} + }, + "Fn::ForEach::UniqueLoopName4": [ + "param4", + ["B"], + { + "Output${param1}${param2}${param3}${param4}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}"} + }, + "Fn::ForEach::UniqueLoopName": [ + "param5", + ["3"], + { + "Output${param1}${param2}${param3}${param4}${param5}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${param1}${param2}${param3}${param4}${param5}"} + } + } + ] + } + ] + } + ] + } + ] + } + ], + "Output0": { + "Description": "MyDescription", + "Value": {"Ref": "Topic0"} + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesInvalidItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesInvalidItemTest.json new file mode 100644 index 00000000000..d50ab2a9f56 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesInvalidItemTest.json @@ -0,0 +1,39 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "References": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + "key-with-dashes" + ], + { + "Instance${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTest.json new file mode 100644 index 00000000000..d65c94dc8b2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1.1 + ], + { + "Output": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTestExpected.json new file mode 100644 index 00000000000..5bc31aa1280 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesOneItemTestExpected.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Output": { + "Description": "MyDescription", + "Value": "Topic1.1" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTest.json new file mode 100644 index 00000000000..dbbc0cd4c86 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTest.json @@ -0,0 +1,39 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + true + ], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTestExpected.json new file mode 100644 index 00000000000..e3a40c1b742 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachPrimitiveTypesTestExpected.json @@ -0,0 +1,34 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Outputtrue": { + "Description": "MyDescription", + "Value": "Topictrue" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingle.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingle.json new file mode 100644 index 00000000000..57a6efff09a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingle.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingleExpected.json new file mode 100644 index 00000000000..538ac37cc4e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachSingleExpected.json @@ -0,0 +1,34 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + }, + "Output2": { + "Description": "MyDescription", + "Value": "Topic2" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueConflictsWithParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueConflictsWithParameter.json new file mode 100644 index 00000000000..3030b81fbb0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueConflictsWithParameter.json @@ -0,0 +1,41 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "SameParam": { + "Type": "String", + "Default": "SameParam" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "SameParam"}, + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotResolvable.json new file mode 100644 index 00000000000..3751b5ac695 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotResolvable.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "UndefinedValue"}, + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsed.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsed.json new file mode 100644 index 00000000000..c763fc6f9c4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsed.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Output1": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic1"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsedExpected.json new file mode 100644 index 00000000000..b1506be4673 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueNotUsedExpected.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueResolvesToNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueResolvesToNull.json new file mode 100644 index 00000000000..6b919a11c01 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachValueResolvesToNull.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "AWS::NoValue"}, + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyList.json new file mode 100644 index 00000000000..17d7e403788 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyList.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyListExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyListExpected.json new file mode 100644 index 00000000000..a0450d66c78 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsEmptyListExpected.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStrings.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStrings.json new file mode 100644 index 00000000000..d6f39edd4f7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStrings.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [""], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStringsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStringsExpected.json new file mode 100644 index 00000000000..0d1ff849975 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionAsListOfEmptyStringsExpected.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Output": { + "Description": "MyDescription", + "Value": "Topic" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionWithNumericValues.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionWithNumericValues.json new file mode 100644 index 00000000000..7fa973d96d3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithCollectionWithNumericValues.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [1, 2], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameLogicalId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameLogicalId.json new file mode 100644 index 00000000000..95a66a7d038 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameLogicalId.json @@ -0,0 +1,39 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::SameName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "SameName": { + "Description": "MyDescription", + "Value": "Topic0" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameParam.json new file mode 100644 index 00000000000..a08e711ded9 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithConflictingLoopNameParam.json @@ -0,0 +1,41 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::ParamName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsNull.json new file mode 100644 index 00000000000..d7450eb15d4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsNull.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + null, + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsString.json new file mode 100644 index 00000000000..d1d7b1d5ef5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidCollectionAsString.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + "123", + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidFragment.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidFragment.json new file mode 100644 index 00000000000..198efcf5cb2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidFragment.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsEmptyString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsEmptyString.json new file mode 100644 index 00000000000..d69a3faefe0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsEmptyString.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "", + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsList.json new file mode 100644 index 00000000000..30f069a805c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsList.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + ["VariableName"], + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNull.json new file mode 100644 index 00000000000..b775570f155 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNull.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + null, + ["1", "2"], + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNumeric.json new file mode 100644 index 00000000000..7ea3c919a67 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidIdentifierAsNumeric.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + 1, + ["1", "2"], + { + "Output${1}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidLayout.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidLayout.json new file mode 100644 index 00000000000..026677fa704 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithInvalidLayout.json @@ -0,0 +1,14 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoEchoParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoEchoParameter.json new file mode 100644 index 00000000000..70de7bcdb77 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoEchoParameter.json @@ -0,0 +1,29 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "NoEchoList": { + "Type": "CommaDelimitedList", + "NoEcho": true + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "NoEchoList"}, + { + "Output${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json new file mode 100644 index 00000000000..344c9a28a94 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "SameName": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValid.json new file mode 100644 index 00000000000..34e5e04a762 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValid.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Output1": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json new file mode 100644 index 00000000000..9cfb74ab14e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Output1": { + "Description": "MyDescription", + "Value": "Topic1" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyExistingInParentLevel.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyExistingInParentLevel.json new file mode 100644 index 00000000000..b0b7841844d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyExistingInParentLevel.json @@ -0,0 +1,39 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "SameName${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ], + "SameName1": { + "Description": "MyDescription", + "Value": "Topic1" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonAlphaNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonAlphaNumeric.json new file mode 100644 index 00000000000..0a9fa8577c0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonAlphaNumeric.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "key-with-dash"], + { + "Prefix${VariableName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonResolvable.json new file mode 100644 index 00000000000..c88bc49f692 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyNonResolvable.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}${UndefinedValue}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyWithParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyWithParam.json new file mode 100644 index 00000000000..a8a3426291c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/outputs/fnForEachWithOutputKeyWithParam.json @@ -0,0 +1,41 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + }, + "Outputs": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Output${VariableName}${ParamName}": { + "Description": "MyDescription", + "Value": {"Fn::Sub": "Topic${VariableName}"} + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionHasUnresolvableValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionHasUnresolvableValue.json new file mode 100644 index 00000000000..73565bbbdd1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionHasUnresolvableValue.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2", {"Ref": "DoesNotExistValue"}], + { + "Topic${Value}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionNotResolvable.json new file mode 100644 index 00000000000..95f175d9ff7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionNotResolvable.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "Value", + {"Ref": "DoesNotExistCollection"}, + { + "Topic${Value}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${Value}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRef.json new file mode 100644 index 00000000000..89c4a4a2635 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRef.json @@ -0,0 +1,32 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "MyQueueList"}, + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRefExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRefExpected.json new file mode 100644 index 00000000000..79e8eb438c3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachCollectionRefExpected.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "1,2" + } + }, + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElements.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElements.json new file mode 100644 index 00000000000..4e727d1e8bb --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElements.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + }, + "Fn::ForEach::Instances": [ + "LogicalId", + { + "Ref": "InstanceList" + }, + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + } + ], + "InstanceD": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElementsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElementsExpected.json new file mode 100644 index 00000000000..29be8c53af4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachInOrderElementsExpected.json @@ -0,0 +1,52 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "InstanceList": { + "Type": "CommaDelimitedList", + "Default": "InstanceA,InstanceB,InstanceC" + } + }, + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + }, + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + }, + "InstanceB": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + }, + "InstanceC": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + }, + "InstanceD": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": "t3.medium", + "ImageId": "ami-00ad2436e75246bba", + "DisableApiTermination": false + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTest.json new file mode 100644 index 00000000000..6478f77c0ae --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTest.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": ["1", "ami-id1"] + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId", "AmiId"], + [ + {"Fn::FindInMap": ["Buckets", "Properties", "Identifiers"]}, + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTestExpected.json new file mode 100644 index 00000000000..9a3a0e97fb1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsFindInMapTestExpected.json @@ -0,0 +1,37 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "Buckets": { + "Properties": { + "Identifiers": ["1", "ami-id1"] + } + } + }, + "Resources": { + "1": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id1", + "InstanceType": "m5.2xlarge" + } + }, + "2": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id2", + "InstanceType": "m5.2xlarge" + } + }, + "3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreIdentifiersTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreIdentifiersTest.json new file mode 100644 index 00000000000..4380d2a68f1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreIdentifiersTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId", "AmiId"], + [ + [1], + [2], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreValuesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreValuesTest.json new file mode 100644 index 00000000000..3253e3cebeb --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsMoreValuesTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId"], + [ + [1, "ami-id1"], + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "LogicalId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTest.json new file mode 100644 index 00000000000..5316460a432 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId", "AmiId"], + [ + [1, "ami-id1"], + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTestExpected.json new file mode 100644 index 00000000000..c215d48b902 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsTestExpected.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "1": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id1", + "InstanceType": "m5.2xlarge" + } + }, + "2": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id2", + "InstanceType": "m5.2xlarge" + } + }, + "3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithListIdentifierTest.json new file mode 100644 index 00000000000..078cd8ae808 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithListIdentifierTest.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId", ["TopicId"]], + [ + [1, "ami-id1"], + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefListIdentifierTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefListIdentifierTest.json new file mode 100644 index 00000000000..128ea2f48ed --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefListIdentifierTest.json @@ -0,0 +1,32 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyQueueList": { + "Type": "CommaDelimitedList", + "Default": "Ami-Id,TopicId" + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + ["LogicalId", {"Ref": "MyQueueList"}], + [ + [1, "ami-id1"], + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTest.json new file mode 100644 index 00000000000..1227f4aebff --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTest.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,ami-id1" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + [{"Ref": "IdentifierString"}, "AmiId"], + [ + {"Ref": "ValueList"}, + [2, "ami-id2"], + [3, "ami-id3"] + ], + { + "${LogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": { + "Ref": "AmiId" + }, + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTestExpected.json new file mode 100644 index 00000000000..8f71ae10cce --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachListOfListsWithRefTestExpected.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ValueList": { + "Type": "CommaDelimitedList", + "Default": "1,ami-id1" + }, + "IdentifierString": { + "Type": "String", + "Default": "LogicalId" + } + }, + "Resources": { + "1": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id1", + "InstanceType": "m5.2xlarge" + } + }, + "2": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id2", + "InstanceType": "m5.2xlarge" + } + }, + "3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachLoopNameConflictingWithLoopIdentifier.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachLoopNameConflictingWithLoopIdentifier.json new file mode 100644 index 00000000000..d04c66a4f64 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachLoopNameConflictingWithLoopIdentifier.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "TopicList": { + "Type": "CommaDelimitedList", + "Default": "TopicA,TopicB" + } + }, + "Resources": { + "Fn::ForEach::SameLoopName1": [ + "SameLoopName2", + { + "Ref": "TopicList" + }, + { + "${SameLoopName2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "My${SameLoopName2}" + } + } + } + } + ], + "Fn::ForEach::SameLoopName2": [ + "SameLoopName1", + [ + "TopicC" + ], + { + "${SameLoopName1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "My${SameLoopName1}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultiple.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultiple.json new file mode 100644 index 00000000000..8b42b0294f6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultiple.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleExpected.json new file mode 100644 index 00000000000..074d5d2aaaf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleExpected.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfter.json new file mode 100644 index 00000000000..7c724031c70 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfter.json @@ -0,0 +1,40 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Fn::ForEach::UniqueLoopName2": [ + "VariableName", + ["3", "4"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfterExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfterExpected.json new file mode 100644 index 00000000000..074d5d2aaaf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachMultipleForEachAfterExpected.json @@ -0,0 +1,36 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic3" + } + }, + "Topic4": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic4" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNested.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNested.json new file mode 100644 index 00000000000..ea2e7ebd20a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNested.json @@ -0,0 +1,82 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Fn::ForEach::UniqueLoopName4": [ + "param4", + ["B"], + { + "Fn::ForEach::UniqueLoopName5": [ + "param5", + ["3"], + { + "Topic${param1}${param2}${param3}${param4}${param5}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}${param5}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}${param4}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}" + } + } + } + } + ], + "Topic${param1}${param2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}" + } + } + } + } + ], + "Topic${param1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedExpected.json new file mode 100644 index 00000000000..adc1b31393d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedExpected.json @@ -0,0 +1,42 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic1A": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1A" + } + }, + "Topic1A2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1A2" + } + }, + "Topic1A2B": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1A2B" + } + }, + "Topic1A2B3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1A2B3" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingIdentifierName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingIdentifierName.json new file mode 100644 index 00000000000..d578180ef6e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingIdentifierName.json @@ -0,0 +1,82 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Fn::ForEach::UniqueLoopName4": [ + "param1", + ["B"], + { + "Fn::ForEach::UniqueLoopName5": [ + "param5", + ["3"], + { + "Topic${param1}${param2}${param3}${param4}${param5}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}${param5}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}${param4}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}" + } + } + } + } + ], + "Topic${param1}${param2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}" + } + } + } + } + ], + "Topic${param1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingLoopName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingLoopName.json new file mode 100644 index 00000000000..34f1614bb20 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachNestedWithConflictingLoopName.json @@ -0,0 +1,82 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "param1", + ["1"], + { + "Fn::ForEach::UniqueLoopName2": [ + "param2", + ["A"], + { + "Fn::ForEach::UniqueLoopName3": [ + "param3", + ["2"], + { + "Fn::ForEach::UniqueLoopName4": [ + "param4", + ["B"], + { + "Fn::ForEach::UniqueLoopName": [ + "param5", + ["3"], + { + "Topic${param1}${param2}${param3}${param4}${param5}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}${param5}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}${param4}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}${param4}" + } + } + } + } + ], + "Topic${param1}${param2}${param3}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}${param3}" + } + } + } + } + ], + "Topic${param1}${param2}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}${param2}" + } + } + } + } + ], + "Topic${param1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${param1}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesInvalidItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesInvalidItemTest.json new file mode 100644 index 00000000000..ae51e72f9e7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesInvalidItemTest.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + "key-with-dashes" + ], + { + "Instance${VariableName}": { + "Type": "AWS::EC2::Boolean", + "Properties": { + "DisableApiTermination": { + "Ref": "VariableName" + }, + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTest.json new file mode 100644 index 00000000000..c2009bb2721 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTest.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1.1 + ], + { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": { + "Ref": "VariableName" + }, + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTestExpected.json new file mode 100644 index 00000000000..a7dc3012d57 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesOneItemTestExpected.json @@ -0,0 +1,14 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": "1.1", + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTest.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTest.json new file mode 100644 index 00000000000..2fd7e6d042c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTest.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Instances": [ + "VariableName", + [ + 1, + true + ], + { + "Instance${VariableName}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": { + "Ref": "VariableName" + }, + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTestExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTestExpected.json new file mode 100644 index 00000000000..60dcd5258ea --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPrimitiveTypesTestExpected.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Instance1": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": "1", + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + }, + "Instancetrue": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": "true", + "ImageId": "ami-0ff8a91507f77f867", + "InstanceType": "m5.2xlarge" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithConflictingProperty.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithConflictingProperty.json new file mode 100644 index 00000000000..25f316d4932 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithConflictingProperty.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + "InstanceLogicalId", + ["InstanceA", "InstanceB", "InstanceC"], + { + "${InstanceLogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": "ami-id3", + "Fn::ForEach::Properties": [ + "PropertyName", + ["ImageId", "InstanceType", "AvailabilityZone"], + { + "${PropertyName}": {"Fn::FindInMap": [{"Ref": "InstanceLogicalId"}, "Properties", {"Ref": "PropertyName"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResource.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResource.json new file mode 100644 index 00000000000..e4230a76733 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResource.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + } + }, + "Resources": { + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "Fn::ForEach::Properties": [ + "PropertyName", + ["ImageId", "InstanceType"], + { + "${PropertyName}": {"Fn::FindInMap": ["InstanceA", "Properties", {"Ref": "PropertyName"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResourceExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResourceExpected.json new file mode 100644 index 00000000000..64551f82208 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithDefinedResourceExpected.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + } + }, + "Resources": { + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollection.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollection.json new file mode 100644 index 00000000000..afe4a7b804a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollection.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + "InstanceLogicalId", + ["InstanceA", "InstanceB", "InstanceC"], + { + "${InstanceLogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "Fn::ForEach::Properties": [ + "PropertyName", + [], + { + "${PropertyName}": {"Fn::FindInMap": [{"Ref": "InstanceLogicalId"}, "Properties", {"Ref": "PropertyName"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollectionExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollectionExpected.json new file mode 100644 index 00000000000..30876b82726 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithEmptyCollectionExpected.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "InstanceC": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true + } + }, + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true + } + }, + "InstanceB": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true + } + } + }, + "Transform": "AWS::LanguageExtensions" +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidCollection.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidCollection.json new file mode 100644 index 00000000000..e53bc8cc4e7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidCollection.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + "InstanceLogicalId", + ["InstanceA", "InstanceB", "InstanceC"], + { + "${InstanceLogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": "ami-id3", + "Fn::ForEach::Properties": [ + "PropertyName", + [{}], + { + "${PropertyName}": {"Fn::FindInMap": [{"Ref": "InstanceLogicalId"}, "Properties", {"Ref": "PropertyName"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidIdentifier.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidIdentifier.json new file mode 100644 index 00000000000..3d1fda07b99 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithInvalidIdentifier.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + "Param", + ["InstanceA", "InstanceB", "InstanceC"], + { + "${Param}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": "ami-id3", + "Fn::ForEach::Properties": [ + "Param", + ["ImageId", "InstanceType", "AvailabilityZone"], + { + "${Param}": {"Fn::FindInMap": [{"Ref": "Param"}, "Properties", {"Ref": "Param"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEach.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEach.json new file mode 100644 index 00000000000..e1b71a2f4b5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEach.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "InstanceInitiatedShutdownBehavior": "stop", + "InstanceType": "m5.xlarge" + } + } + }, + "Resources": { + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "Fn::ForEach::Properties": [ + "PropertyName1", + ["Instance"], + { + "Fn::ForEach::Properties2": [ + "PropertyName2", + ["Type", "InitiatedShutdownBehavior"], + { + "${PropertyName1}${PropertyName2}": {"Fn::FindInMap": ["InstanceA", "Properties", {"Fn::Sub": "${PropertyName1}${PropertyName2}"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEachExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEachExpected.json new file mode 100644 index 00000000000..ae6f6de7c93 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithNestedForEachExpected.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "InstanceInitiatedShutdownBehavior": "stop", + "InstanceType": "m5.xlarge" + } + } + }, + "Resources": { + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "InstanceInitiatedShutdownBehavior": "stop", + "InstanceType": "m5.xlarge" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEach.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEach.json new file mode 100644 index 00000000000..4358d0db2cf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEach.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "Fn::ForEach::Instances": [ + "InstanceLogicalId", + ["InstanceA", "InstanceB", "InstanceC"], + { + "${InstanceLogicalId}": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "Fn::ForEach::Properties": [ + "PropertyName", + ["ImageId", "InstanceType", "AvailabilityZone"], + { + "${PropertyName}": {"Fn::FindInMap": [{"Ref": "InstanceLogicalId"}, "Properties", {"Ref": "PropertyName"}, {"DefaultValue": {"Ref": "AWS::NoValue"}}]} + } + ] + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEachExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEachExpected.json new file mode 100644 index 00000000000..9312c715f1a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachPropertiesWithinResourcesForEachExpected.json @@ -0,0 +1,50 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Mappings": { + "InstanceA": { + "Properties": { + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Properties": { + "ImageId": "ami-id2" + } + }, + "InstanceC": { + "Properties": { + "ImageId": "ami-id3", + "InstanceType": "m5.2xlarge", + "AvailabilityZone": "us-east-1a" + } + } + }, + "Resources": { + "InstanceC": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id3", + "AvailabilityZone": "us-east-1a", + "InstanceType": "m5.2xlarge" + } + }, + "InstanceA": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id1", + "InstanceType": "m5.xlarge" + } + }, + "InstanceB": { + "Type": "AWS::EC2::Instance", + "Properties": { + "DisableApiTermination": true, + "ImageId": "ami-id2" + } + } + }, + "Transform": "AWS::LanguageExtensions" +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingle.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingle.json new file mode 100644 index 00000000000..8125b51c79e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingle.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingleExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingleExpected.json new file mode 100644 index 00000000000..11100294493 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachSingleExpected.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic2" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueConflictsWithParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueConflictsWithParameter.json new file mode 100644 index 00000000000..3b7482a8389 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueConflictsWithParameter.json @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "SameParam": { + "Type": "String", + "Default": "SameParam" + } + }, + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "SameParam"}, + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotResolvable.json new file mode 100644 index 00000000000..df9b2d965fa --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotResolvable.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "UndefinedValue"}, + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsed.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsed.json new file mode 100644 index 00000000000..dcd87cb51e9 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsed.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic1" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsedExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsedExpected.json new file mode 100644 index 00000000000..2f87e11a054 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueNotUsedExpected.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + }, + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueResolvesToNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueResolvesToNull.json new file mode 100644 index 00000000000..fed39fdebdc --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachValueResolvesToNull.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + {"Ref": "AWS::NoValue"}, + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyList.json new file mode 100644 index 00000000000..a9a598bc8b8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyList.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyListExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyListExpected.json new file mode 100644 index 00000000000..a0450d66c78 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsEmptyListExpected.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStrings.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStrings.json new file mode 100644 index 00000000000..0126907fc79 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStrings.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [""], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStringsExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStringsExpected.json new file mode 100644 index 00000000000..d9f274b0c9e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionAsListOfEmptyStringsExpected.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionWithNumericValues.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionWithNumericValues.json new file mode 100644 index 00000000000..a0dab71e2af --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithCollectionWithNumericValues.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + [1, 2], + { + "Topic${1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameLogicalId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameLogicalId.json new file mode 100644 index 00000000000..0e99344a39a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameLogicalId.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::SameName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "SameName": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "Topic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameParam.json new file mode 100644 index 00000000000..252d025c9d6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithConflictingLoopNameParam.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Resources": { + "Fn::ForEach::ParamName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsNull.json new file mode 100644 index 00000000000..c5652f70d4d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsNull.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + null, + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsString.json new file mode 100644 index 00000000000..4ec9b38333d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidCollectionAsString.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + "1,23", + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidFragment.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidFragment.json new file mode 100644 index 00000000000..8cf9047d285 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidFragment.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsEmptyString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsEmptyString.json new file mode 100644 index 00000000000..de779581a99 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsEmptyString.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "", + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsList.json new file mode 100644 index 00000000000..180af686577 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsList.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + ["VariableName"], + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNull.json new file mode 100644 index 00000000000..210aba432b1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNull.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + null, + ["1", "2"], + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNumeric.json new file mode 100644 index 00000000000..15c5f698c42 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidIdentifierAsNumeric.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + 1, + ["1", "2"], + { + "Topic${1}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidLayout.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidLayout.json new file mode 100644 index 00000000000..60d99f4b3a4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithInvalidLayout.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "Fn::ForEach::UniqueLoopName": [], + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoEchoParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoEchoParameter.json new file mode 100644 index 00000000000..d2a1a7d970f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoEchoParameter.json @@ -0,0 +1,32 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "NoEchoList": { + "Type": "CommaDelimitedList", + "NoEcho": true + } + }, + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + {"Ref": "NoEchoList"}, + { + "Topic${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "Topic0": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic0" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json new file mode 100644 index 00000000000..1613ec8c45d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndNotValid.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "SameName": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValid.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValid.json new file mode 100644 index 00000000000..9b9657f4c76 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValid.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1"], + { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json new file mode 100644 index 00000000000..3ac33632ae6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithNoIdentifierInOutputKeyAndValidExpected.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyExistingInParentLevel.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyExistingInParentLevel.json new file mode 100644 index 00000000000..8a1fe76a28a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyExistingInParentLevel.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "SameName${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ], + "SameName1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "MyTopic1" + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonAlphaNumeric.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonAlphaNumeric.json new file mode 100644 index 00000000000..4fe1f4c0c15 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonAlphaNumeric.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "key-with-dash"], + { + "Prefix${VariableName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonResolvable.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonResolvable.json new file mode 100644 index 00000000000..1c333fe6890 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyNonResolvable.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}${UndefinedValue}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyWithParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyWithParam.json new file mode 100644 index 00000000000..05f286b2139 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/forEach/resources/fnForEachWithOutputKeyWithParam.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ParamName": { + "Type": "String", + "Default": "ABC" + } + }, + "Resources": { + "Fn::ForEach::UniqueLoopName": [ + "VariableName", + ["1", "2"], + { + "Topic${VariableName}${ParamName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::Sub": "MyTopic${VariableName}" + } + } + } + } + ] + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfIntrinsicFunction.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfIntrinsicFunction.json new file mode 100644 index 00000000000..0283a565fd4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfIntrinsicFunction.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ParameterA": { + "Type": "String", + "Default": "Bucket" + }, + "ParameterB": { + "Type": "String", + "Default": "Arn" + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Fn::GetAtt": [{ "Ref": "ParameterA" }, {"Ref": "ParameterB"}] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInAttrName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInAttrName.json new file mode 100644 index 00000000000..1cb53012702 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInAttrName.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Fn::GetAtt": ["LogicalId", {"Ref": "ParameterB"}] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInLogicalId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInLogicalId.json new file mode 100644 index 00000000000..ec791a65e31 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/getAttOfUnresolvableIntrinsicFunctionInLogicalId.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Fn::GetAtt": [{ "Ref": "ParameterA" }, "Arn"] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/noEchoParameter.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noEchoParameter.json new file mode 100644 index 00000000000..59176b6ce7d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noEchoParameter.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "password": { + "Type": "String", + "NoEcho": "true" + } + }, + "Resources" : { + "Database" : { + "Type": "AWS::RDS::DBInstance", + "Properties": { + "DBName": "MyTable", + "Engine": "MySQL", + "MasterUsername": "Tommy", + "DBInstanceClass": "db.m6i", + "MasterUserPassword": { "Ref" : "password" } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttribute.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttribute.json new file mode 100644 index 00000000000..9c971f0ebd9 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttribute.json @@ -0,0 +1,9 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackId.json new file mode 100644 index 00000000000..6f5ae72c5f8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackId.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Ref" : "AWS::StackId" } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackName.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackName.json new file mode 100644 index 00000000000..362a2ee167f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/noResourceAttributeWithAwsStackName.json @@ -0,0 +1,21 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "IsInternalAccount": { + "Fn::Or": [ + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "unit-test-account-id" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "456" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "789" ] } + ] + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Fn::If": ["IsInternalAccount", "Name1", { "Ref" : "AWS::StackName" } ] } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithAwsNoValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithAwsNoValue.json new file mode 100644 index 00000000000..00adb029494 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithAwsNoValue.json @@ -0,0 +1,10 @@ +{ + "Transform": "LanguageExtensionsDev", + "Outputs": { + "MyOutput": { + "Value": { + "Ref": "AWS::NoValue" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithNullSub.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithNullSub.json new file mode 100644 index 00000000000..62915e03311 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/outputWithNullSub.json @@ -0,0 +1,19 @@ +{ + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + }, + "Outputs": { + "MyOutput": { + "Value": { + "Fn::Sub": [ + "https://${ApiGatewayId}.execute-api", + { + "ApiGatewayId": null + } + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/propertiesWithNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/propertiesWithNull.json new file mode 100644 index 00000000000..3246ad008db --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/propertiesWithNull.json @@ -0,0 +1,11 @@ +{ + "Resources": { + "cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "cluster", + "ClusterSettings": null + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunction.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunction.json new file mode 100644 index 00000000000..2377fa8c36c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunction.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ParameterA": { + "Type": "String", + "Default": "ParameterB" + }, + "ParameterB": { + "Type": "String", + "Default": "FinalValue" + } + }, + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Ref": { "Ref": "ParameterA" } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunctionWithParameterValueAssigned.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunctionWithParameterValueAssigned.json new file mode 100644 index 00000000000..5c4db9a8c10 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfIntrinsicFunctionWithParameterValueAssigned.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "Param": { + "Type": "String", + "Default": "" + } + }, + "Resources": { + "MyQueue": { + "Type": "AWS::SNS::Topic", + "Properties": { + "QueueName": { + "Ref": { + "Fn::Sub": "Param" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfUnresolvableIntrinsicFunction.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfUnresolvableIntrinsicFunction.json new file mode 100644 index 00000000000..6a6dfd85598 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/refOfUnresolvableIntrinsicFunction.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "queue": { + "Type": "String", + "Default": "MyQueue1" + } + }, + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Ref": { "Ref": "queue" } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToMultipleParams.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToMultipleParams.json new file mode 100644 index 00000000000..9e6d144cab1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToMultipleParams.json @@ -0,0 +1,22 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "Param1": { + "Type": "String" + }, + "Param2": { + "Type": "String" + } + }, + "Resources": { + "WaitHandle1": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "DeletionPolicy": { "Ref": "ResourceAttributeParameter1" } + }, + "WaitHandle2": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "UpdateReplacePolicy": { "Ref": "ResourceAttributeParameter2" } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToParam.json new file mode 100644 index 00000000000..b5f44bd014b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToParam.json @@ -0,0 +1,16 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ResourceAttributeParameter": { + "Type": "String", + "Default": "Delete" + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Ref": "ResourceAttributeParameter" } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToPartition.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToPartition.json new file mode 100644 index 00000000000..eeeb82deb22 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToPartition.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings" : { + "PartitionMap" : { + "aws" : { + "$RESOURCE_ATTRIBUTE": "Delete" + }, + "aws-us-gov" : { + "$RESOURCE_ATTRIBUTE": "Retain" + }, + "aws-cn" : { + "$RESOURCE_ATTRIBUTE": "Delete" + }, + "aws-iso" : { + "$RESOURCE_ATTRIBUTE": "Retain" + }, + "aws-iso-b" : { + "$RESOURCE_ATTRIBUTE": "Retain" + } + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { + "Fn::FindInMap" : [ + "PartitionMap", + { "Ref" : "AWS::Partition" }, + "$RESOURCE_ATTRIBUTE" + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToString.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToString.json new file mode 100644 index 00000000000..ed4fdff66b7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesReferringToString.json @@ -0,0 +1,10 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": "Retain" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAccountIdPseudoParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAccountIdPseudoParam.json new file mode 100644 index 00000000000..bcb6a096487 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAccountIdPseudoParam.json @@ -0,0 +1,19 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "IsInternalAccount": { + "Fn::Or": [ + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "unit-test-account-id" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "456" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "789" ] } + ] + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Fn::If": ["IsInternalAccount", "Delete", "Retain" ] } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsNoValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsNoValue.json new file mode 100644 index 00000000000..13301aa0c94 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsNoValue.json @@ -0,0 +1,19 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "$RESOURCE_ATTRIBUTE": {"Ref": "AWS::NoValue"} + } + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { + "Fn::FindInMap" : [ "RegionMap", "us-east-1", "$RESOURCE_ATTRIBUTE" ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsStackId.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsStackId.json new file mode 100644 index 00000000000..cfbbc67f809 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithAwsStackId.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "IsInternalAccount": { + "Fn::Or": [ + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "unit-test-account-id" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "456" ] }, + { "Fn::Equals": [ { "Ref": "AWS::AccountId" }, "789" ] } + ] + } + }, + "Mappings" : { + "StackIdMap" : { + "arn:aws:cloudformation:us-west-2:123456789012:stack/teststack/51af3dc0-da77-11e4-872e-1234567db123" : { + "$RESOURCE_ATTRIBUTE": "Retain" + }, + "arn:aws:cloudformation:us-west-2:123456789012:stack/teststack/51af3dc0-da77-11e4-872e-1234567db124" : { + "$RESOURCE_ATTRIBUTE": "Delete" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { "Ref" : "AWS::StackId" } + } + }, + "WaitHandle1": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { + "Fn::FindInMap" : [ + "StackIdMap", + { "Ref" : "AWS::StackId" }, + "$RESOURCE_ATTRIBUTE" + ] + } + }, + "WaitHandle2": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Fn::If": ["IsInternalAccount", "Delete", { "Ref" : "AWS::StackId" } ] } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithCondition.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithCondition.json new file mode 100644 index 00000000000..bdc511e6da0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithCondition.json @@ -0,0 +1,21 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "Stage": { + "Type": "String", + "Default": "gamma" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Fn::If": ["IsProd", "Retain", "Delete" ] } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnGetAtt.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnGetAtt.json new file mode 100644 index 00000000000..ef529c6e455 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnGetAtt.json @@ -0,0 +1,16 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Fn::GetAtt" : ["Bucket", "Arn"] } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnSubWithRightValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnSubWithRightValue.json new file mode 100644 index 00000000000..756bd5f29c8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithFnSubWithRightValue.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "ResourceAttributeParameter": { + "Type": "String", + "Default": "Delete" + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { + "Fn::Sub" : [ + "${Policy}", + { + "Policy": { + "Ref": "ResourceAttributeParameter" + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithMap.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithMap.json new file mode 100644 index 00000000000..b83aedbbfef --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithMap.json @@ -0,0 +1,32 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "Region": { + "Type": "String", + "Default": "us-west-1" + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "$RESOURCE_ATTRIBUTE": "Retain" + }, + "us-west-1" : { + "$RESOURCE_ATTRIBUTE": "Delete" + } + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { + "Fn::FindInMap" : [ + "RegionMap", + { "Ref" : "Region" }, + "$RESOURCE_ATTRIBUTE" + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithReferenceToOtherResource.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithReferenceToOtherResource.json new file mode 100644 index 00000000000..82c64b7b9e6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithReferenceToOtherResource.json @@ -0,0 +1,16 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Ref" : "Bucket" } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithRegionPseudoParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithRegionPseudoParam.json new file mode 100644 index 00000000000..4b77f90ed2a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithRegionPseudoParam.json @@ -0,0 +1,15 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "IsUsEast1": { + "Fn::Equals": [ { "Ref": "AWS::Region" }, "us-east-1" ] + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": { "Fn::If": ["IsUsEast1", "Retain", "Delete" ] } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithWrongValueList.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithWrongValueList.json new file mode 100644 index 00000000000..a774762e2db --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceAttributesWithWrongValueList.json @@ -0,0 +1,10 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "$RESOURCE_ATTRIBUTE": ["NotOneOfDeleteRetainSnapshot", "InvalidValue"] + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceWithBase64String.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceWithBase64String.json new file mode 100644 index 00000000000..dc91053a998 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/resourceWithBase64String.json @@ -0,0 +1,14 @@ +{ + "Resources": { + "EksNodeLaunchTemplate": { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateData": { + "UserData": { + "Fn::Base64": "echo 'Hello World'" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithBoolean.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithBoolean.json new file mode 100644 index 00000000000..dc78b42aa1a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithBoolean.json @@ -0,0 +1,34 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "SomeCondition": { + "Fn::Equals": [ true, { "Ref": "SomeParam" } ] + } + }, + "Parameters": { + "SomeParam": { + "AllowedValues": [ + true, + false + ], + "Default": "true", + "Type": "String" + } + }, + "Resources": { + "SomeResource": { + "Type": "AWS::Elasticsearch::Domain", + "Properties": { + "DomainEndpointOptions": { + "CustomEndpointEnabled": { + "Fn::If": [ + "SomeCondition", + true, + false + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithNumber.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithNumber.json new file mode 100644 index 00000000000..686b2795fa8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateComparingStringWithNumber.json @@ -0,0 +1,28 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "ExpectedTrueCondition": { + "Fn::Equals": [ + "0", + 0 + ] + } + }, + "Resources": { + "WaitConditionHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + }, + "Outputs": { + "Output": { + "Value": { + "Fn::If": [ + "ExpectedTrueCondition", + "ValueIfTrue", + "ValueIfFalse" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithDefaultValue.json new file mode 100644 index 00000000000..583eca60e9e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithDefaultValue.json @@ -0,0 +1,45 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C", + { + "DefaultValue": "MyTopicName" + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithMapNameAsRefFunction.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithMapNameAsRefFunction.json new file mode 100644 index 00000000000..0156f01dae1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithMapNameAsRefFunction.json @@ -0,0 +1,44 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + { + "Ref": "MyParam" + }, + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithToJsonStringParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithToJsonStringParam.json new file mode 100644 index 00000000000..1ccea1a1fc7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWhenFalseConditionInResourceWithToJsonStringParam.json @@ -0,0 +1,47 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + { + "Ref": "AWS::StackName" + }, + { + "Fn::ToJsonString": {"Fn::Length": [1, 2]} + }, + "C", + { + "DefaultValue": "MyTopicName" + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithDifferentConditionsPerResources.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithDifferentConditionsPerResources.json new file mode 100644 index 00000000000..752b077af7e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithDifferentConditionsPerResources.json @@ -0,0 +1,70 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + }, + "MyParam2": { + "Type": "String", + "Default": "B" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + }, + "TrueCondition": { + "Fn::Equals": [ + "true", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C", + { + "DefaultValue": "MyTopicName" + } + ] + } + } + }, + "MyTopic1": { + "Condition": "TrueCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam2" + }, + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithInvalidRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithInvalidRef.json new file mode 100644 index 00000000000..7502cdbd1c8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithInvalidRef.json @@ -0,0 +1,47 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + { + "Ref": "AWS::StackName" + }, + { + "Ref": "Undefined" + }, + "C", + { + "DefaultValue": "MyTopicName" + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfDiscardedWhenFalseCondition.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfDiscardedWhenFalseCondition.json new file mode 100644 index 00000000000..3a1a4e70e27 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfDiscardedWhenFalseCondition.json @@ -0,0 +1,51 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::If": [ + "FalseCondition", + { + "Fn::FindInMap": [ + "A", + "B", + "G", + { + "DefaultValue": "Undefined" + } + ] + }, + { + "Ref": "Undefined" + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfResolvableAsFalseConditionWithDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfResolvableAsFalseConditionWithDefaultValue.json new file mode 100644 index 00000000000..0a2c367b847 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFindInMapWithinFnIfResolvableAsFalseConditionWithDefaultValue.json @@ -0,0 +1,51 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::If": [ + "FalseCondition", + { + "Ref": "Undefined" + }, + { + "Fn::FindInMap": [ + "A", + {"Ref": "MyParam"}, + "C", + { + "DefaultValue": {"Ref": "Undefined"} + } + ] + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithInvalidStringPath.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithInvalidStringPath.json new file mode 100644 index 00000000000..47788f456ae --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithInvalidStringPath.json @@ -0,0 +1,34 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Mappings": { + "A": { + "B": { + "C": "D" + } + } + }, + "Conditions": { + "TrueCondition": { + "Fn::Equals": [ + "false", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "TrueCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + "Undefined", + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithNestedInMap.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithNestedInMap.json new file mode 100644 index 00000000000..79bf38459a4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenFalseConditionInResourceWithNestedInMap.json @@ -0,0 +1,48 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "D" + } + } + }, + "Conditions": { + "TrueCondition": { + "Fn::Equals": [ + "false", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "TrueCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + }, + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInOutputWithInvalidParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInOutputWithInvalidParam.json new file mode 100644 index 00000000000..f23d4a1305b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInOutputWithInvalidParam.json @@ -0,0 +1,63 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "Hello" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + }, + "TrueCondition": { + "Fn::Equals": [ + "true", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } + }, + "Outputs": { + "MyOutput": { + "Condition": "TrueCondition", + "Description": "MyDescription", + "Value": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInResourceWithInvalidParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInResourceWithInvalidParam.json new file mode 100644 index 00000000000..3ef9689e9ff --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapInWhenTrueConditionInResourceWithInvalidParam.json @@ -0,0 +1,42 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "D" + } + } + }, + "Conditions": { + "TrueCondition": { + "Fn::Equals": [ + "true", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "TrueCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInOutputWithRefParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInOutputWithRefParam.json new file mode 100644 index 00000000000..1d31c01b78a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInOutputWithRefParam.json @@ -0,0 +1,63 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "WOW" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + }, + "TrueCondition": { + "Fn::Equals": [ + "true", + "true" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } + }, + "Outputs": { + "MyOutput": { + "Condition": "FalseCondition", + "Description": "MyDescription", + "Value": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInResourceWithRefParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInResourceWithRefParam.json new file mode 100644 index 00000000000..8aad1f8e808 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateFnInMapWhenFalseConditionInResourceWithRefParam.json @@ -0,0 +1,42 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MyParam": { + "Type": "String", + "Default": "G" + } + }, + "Mappings": { + "A": { + "B": { + "C": "WOW" + } + } + }, + "Conditions": { + "FalseCondition": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "MyTopic": { + "Condition": "FalseCondition", + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "A", + { + "Ref": "MyParam" + }, + "C" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbers.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbers.json new file mode 100644 index 00000000000..2481e582332 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbers.json @@ -0,0 +1,29 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MaxVal": { + "Type": "Number", + "Default": 99999999999.99 + } + }, + "Resources": { + "NullResource": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "Metadata": { + "RandomSmallInteger": 10, + "EmbeddedValue": 99999999999.99, + "ParameterReference": { + "Ref": "MaxVal" + }, + "IncludedTransform": { + "Fn::Transform": { + "Name": "AWS::Include", + "Parameters": { + "maximum": 99999999999.99 + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbersResolved.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbersResolved.json new file mode 100644 index 00000000000..b4f366e7b5c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDecimalNumbersResolved.json @@ -0,0 +1,30 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "MaxVal": { + "Type": "Number", + "Default": 99999999999.99 + } + }, + "Resources": { + "NullResource": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "Metadata": { + "RandomSmallInteger": 10, + "EmbeddedValue": 99999999999.99, + "ParameterReference": 99999999999.99, + "IncludedTransform": { + "Fn::Transform": { + "Name": "AWS::Include", + "Parameters": { + "maximum": 99999999999.99 + } + } + } + } + }, + "Outputs":{}, + "AWSTemplateFormatVersion":"2010-09-09", + "Hooks":{} + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDifferentTypesOfParameters.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDifferentTypesOfParameters.json new file mode 100644 index 00000000000..2a5e2f7f66f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithDifferentTypesOfParameters.json @@ -0,0 +1,33 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters" : { + "stringTypeParam": { + "Type": "String", + "Default": "someString" + }, + "listTypeParam": { + "Type": "CommaDelimitedList", + "Default": "someCDL" + }, + "numberTypeParam": { + "Type": "Number", + "Default": 0 + } + }, + "Resources": { + "Queue1": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { "Ref": "stringTypeParam" } + } + }, + "Queue2": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { "Fn::Select" : [ 0, { "Ref": "listTypeParam" } ] }, + "DelaySeconds": { "Ref": "numberTypeParam" } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFindInMapDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFindInMapDefaultValue.json new file mode 100644 index 00000000000..3e6b722463c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFindInMapDefaultValue.json @@ -0,0 +1,65 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "HVM64" : "ami-0ff8a91507f77f867", "HVMG2" : "ami-0a584ac55a7631c0c" + }, + "us-west-1" : { + "HVM64" : "ami-0bdb828fd58c52235", "HVMG2" : "ami-066ee5fd4a9ef77f1" + }, + "eu-west-1" : { + "HVM64" : "ami-047bb4163c506cd98", "HVMG2" : "ami-0a7c483d527806435" + }, + "ap-southeast-1" : { + "HVM64" : "ami-08569b978cc4dfa10", "HVMG2" : "ami-0be9df32ae9f92309" + }, + "ap-northeast-1" : { + "HVM64" : "ami-06cd52961ce9f0d85", "HVMG2" : "ami-053cdd503598e4a9d" + } + } + }, + + "Resources" : { + "myEC2Instance" : { + "Type" : "AWS::EC2::Instance", + "Properties" : { + "ImageId" : { + "Fn::FindInMap" : [ + "RegionMap", + "us-east-1", + "not-found", + {"DefaultValue": "ami-0ff8a91507f77f867"} + ] + }, + "InstanceType" : "m1.small" + } + }, + "myEC2Instance2": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "RegionMap", + {"Fn::Join": ["-", ["us", "east", "1"]]}, + "HVM64" + ] + } + } + }, + "myEC2Instance3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "RegionMap", + "us-east-1", + "not-found", + {"DefaultValue": {"Fn::Join": ["-", ["ami", "0ff8a91507f77f867"]]}} + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnJoinWithNoValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnJoinWithNoValue.json new file mode 100644 index 00000000000..8e310aa6329 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnJoinWithNoValue.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::Join": [ + ",", + [ + "A,B", + { + "Ref": "AWS::NoValue" + } + ] + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthAndIncorrectLayout.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthAndIncorrectLayout.json new file mode 100644 index 00000000000..632e9183b78 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthAndIncorrectLayout.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { "Fn::Length" : "q1" } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthInConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthInConditions.json new file mode 100644 index 00000000000..88e7f6dd9aa --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthInConditions.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "ParameterList": { + "Type": "String" + } + }, + "Conditions": { + "FnEquals": { + "Fn::Equals": [ + { + "Fn::Length": {"Fn::Split": [",", {"Ref": "ParameterList"}]} + }, + 2 + ] + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnBase64.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnBase64.json new file mode 100644 index 00000000000..851d57ccd33 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnBase64.json @@ -0,0 +1,16 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + "q1,", + { "Fn::Base64" : "q6" } ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnFindInMap.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnFindInMap.json new file mode 100644 index 00000000000..2c976f04eb1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnFindInMap.json @@ -0,0 +1,43 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "QueueName": { + "Type": "String", + "Default": "q3" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "q5" + }, + "us-west-1" : { + "Key": "q5" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + { + "Fn::FindInMap" : [ + "RegionMap", + { "Ref" : "Region" }, + "Key" + ] + }, + "q1" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnIf.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnIf.json new file mode 100644 index 00000000000..b415155db6e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnIf.json @@ -0,0 +1,28 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + { "Fn::If": ["IsProd", "q4", "q5" ] }, + "q1", + "q2" ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnJoin.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnJoin.json new file mode 100644 index 00000000000..6077c0fb85c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnJoin.json @@ -0,0 +1,27 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { + "Fn::Length": [ + { + "Fn::Join": [ + ":", + [ + "q1", + "q2", + "q3" + ] + ] + }, + "q4" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSelect.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSelect.json new file mode 100644 index 00000000000..38479af08de --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSelect.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + { "Fn::Select" : [ "1", [ "q1", "q2", "q3" ] ] }, + "q3", + "q4" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSplit.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSplit.json new file mode 100644 index 00000000000..23da60605a2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSplit.json @@ -0,0 +1,21 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { + "Fn::Length": + { + "Fn::Split": [ + "|", + "q1|q2|q3|q4|q5|q6" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSub.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSub.json new file mode 100644 index 00000000000..3d896f0db0d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToFnSub.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "QueueName": { + "Type": "String", + "Default": "q3" + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + { "Fn::Sub" : [ + "${QName}", + { + "QName": { + "Ref": "QueueName" + } + } + ] + }, + "q2"] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToResolvableFunctions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToResolvableFunctions.json new file mode 100644 index 00000000000..163fdccce1e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthReferringToResolvableFunctions.json @@ -0,0 +1,65 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "QueueName": { + "Type": "String", + "Default": "q3" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "q5" + }, + "us-west-1" : { + "Key": "q5" + } + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "KmsMasterKeyId": "alias/aws/sqs", + "DelaySeconds": { "Fn::Length" : [ + { "Fn::Join": [ ",", [ "q1", + { "Fn::Select" : [ "1", { "Fn::Split" : [ "|" , "q1|q2|q3" ] } ] }, + { "Fn::Sub" : [ + "${QName}", + { + "QName": { + "Ref": "QueueName" + } + } + ] + }, + { "Fn::If": ["IsProd", "q4", "q5" ] }, + { + "Fn::FindInMap" : [ + "RegionMap", + { "Ref" : "Region" }, + "Key" + ] + }, + { "Fn::Base64" : "q6" } ] ] + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthSimple.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthSimple.json new file mode 100644 index 00000000000..d746f4fb11f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthSimple.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySeconds": { "Fn::Length" : [ "q1", "q2", "q3" ] } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRef.json new file mode 100644 index 00000000000..a3f421d9f50 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRef.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "queues": { + "Type": "CommaDelimitedList" + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySeconds": { "Fn::Length" : { "Ref": "queues" } } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRefToCDLInConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRefToCDLInConditions.json new file mode 100644 index 00000000000..50f0afc07ef --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnLengthWithRefToCDLInConditions.json @@ -0,0 +1,23 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "queues": { + "Type": "CommaDelimitedList", + "Default": "m4.large,m4.xlarge,m4.2xlarge" + } + }, + "Conditions": { + "Has3InstanceType": { + "Fn::Equals": [ { "Fn::Length": { "Ref": "queues" } }, 3 ] + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySeconds": { "Fn::Length" : { "Ref": "queues" } } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnMapInCondition.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnMapInCondition.json new file mode 100644 index 00000000000..820cd87bb76 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithFnMapInCondition.json @@ -0,0 +1,13 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "StatusVersion": { + "Fn::FindInMap": ["ServiceVersionsMap","status","dev"] + } + }, + "Resources": { + "WaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsAndDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsAndDefaultValue.json new file mode 100644 index 00000000000..165ab7fc2f4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsAndDefaultValue.json @@ -0,0 +1,32 @@ +{ + "accountId":"123456789012", + "fragment": { + "AWSTemplateFormatVersion":"2010-09-09", + "Description":"AWS CloudFormation Template to create a security group", + "Parameters": { + "PassedVPCId": { + "Description":"VPCID for the endpoints", + "Default":"", + "Type":"String" + } + }, + "Resources": { + "DefaultSecurityGroup": { + "Type":"AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription":"Test SG", + "VpcId": { + "Ref":"PassedVPCId" + } + } + } + } + }, + "transformId":"AWS::LanguageExtensions", + "requestId":"123", + "region":"us-east-1", + "templateParameterValues": { + "PassedVPCId":"{{IntrinsicFunction:test/TestVPC/Ref}}" + } +} + diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsInOutput.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsInOutput.json new file mode 100644 index 00000000000..bce9a628a4c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsInOutput.json @@ -0,0 +1,67 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "parameter1": { + "Type": "String" + } + }, + "Resources": { + "EC2Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "BlockDeviceMappings": { + "DeviceName": "/dev/xvda", + "Ebs": { + "DeleteOnTermination": "true", + "VolumeSize": "30", + "VolumeType": "gp2" + } + }, + "InstanceType": "t2.large" + } + } + }, + "Outputs": { + "InstanceID": { + "Description": "The Instance ID", + "Value": { + "Fn::Join": [ + "-", + [ + { + "Ref": "EC2Instance" + }, + { + "Fn::Select": [ + {"Fn::Length": [1, 2, 3]}, + ["", "", "", "test"] + ] + } + ] + ] + }, + "Export": { + "Name": { + "Fn::Join": [ + "-", + ["1", "2", + { + "Fn::Select": [ + {"Fn::Length": [1, 2]}, + ["", "", "test"] + ] + } + ] + ] + } + } + }, + "Parameter1Output": { + "Description": "Output parameter", + "Value": {"Fn::Join": [ + "-", + [{"Ref": "parameter1"}, "test"] + ]} + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsWithoutDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsWithoutDefaultValue.json new file mode 100644 index 00000000000..8f70b82f8c1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicFunctionsWithoutDefaultValue.json @@ -0,0 +1,31 @@ +{ + "accountId":"123456789012", + "fragment": { + "AWSTemplateFormatVersion":"2010-09-09", + "Description":"AWS CloudFormation Template to create a security group", + "Parameters": { + "PassedVPCId": { + "Description":"VPCID for the endpoints", + "Type":"String" + } + }, + "Resources": { + "DefaultSecurityGroup": { + "Type":"AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription":"Test SG", + "VpcId": { + "Ref":"PassedVPCId" + } + } + } + } + }, + "transformId":"AWS::LanguageExtensions", + "requestId":"123", + "region":"us-east-1", + "templateParameterValues": { + "PassedVPCId":"{{IntrinsicFunction:test/TestVPC/Ref}}" + } +} + diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocument.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocument.json new file mode 100644 index 00000000000..9ba7b65b44f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocument.json @@ -0,0 +1,59 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "RunLambdaInVPC": { + "Fn::Equals": [ 3, 3 ] + } + }, + "Resources": { + "LambdaRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "Tags": [ + { + "Fn::If": [ + "RunLambdaInVPC", + { + "Key": "cfn", + "Value": "condition" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + { + "Key": "already", + "Value": "exists" + } + ], + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Fn::If": [ + "RunLambdaInVPC", + { + "Action": [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocumentResolved.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocumentResolved.json new file mode 100644 index 00000000000..07b2bad5601 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithIntrinsicInPolicyDocumentResolved.json @@ -0,0 +1,43 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Conditions": { + "RunLambdaInVPC": { + "Fn::Equals": [ 3, 3 ] + } + }, + "Resources": { + "LambdaRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "Tags": [ + { + "Key": "cfn", + "Value": "condition" + }, + { + "Key": "already", + "Value": "exists" + } + ], + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidConditionRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidConditionRef.json new file mode 100644 index 00000000000..d4d6ff5903a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidConditionRef.json @@ -0,0 +1,25 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Conditions": { + "ConditionA": { + "Fn::Equals": [ + "IAD", + "IAD" + ] + }, + "ConditionB": { + "Fn::And": [ + {"Ref": "ConditionA"}, + {"Ref": "ConditionA"} + ] + } + }, + "Resources": { + "waitConditionHandles": { + "Condition": "ConditionB", + "Type" : "AWS::CloudFormation::WaitConditionHandle", + "Properties" : { + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidFnSelectIndex.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidFnSelectIndex.json new file mode 100644 index 00000000000..7ec744e29ba --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidFnSelectIndex.json @@ -0,0 +1,33 @@ +{ + "Resources": { + "RecordSetB": { + "Type": "AWS::Route53::RecordSet", + "DependsOn": "RedirectCloudFrontDistribution", + "Properties": { + "AliasTarget": { + "DNSName": {"Fn::GetAtt": ["RedirectCloudFrontDistribution", "DomainName"]}, + "HostedZoneId": "Z2FDTNDATAQYW2" + }, + "Comment": { + "Fn::Join": [ + "", + [ + "Record set for the ", + { + "Fn::Select": [1, ["1"]] + }, + " redirect" + ] + ] + }, + "HostedZoneId": { + "Ref": "HostedZoneId" + }, + "Name": { + "Fn::Select": [1, {"Ref": "RedirectHostnames"}] + }, + "Type": "A" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidIntrinsicFunctionType.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidIntrinsicFunctionType.json new file mode 100644 index 00000000000..90070192471 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithInvalidIntrinsicFunctionType.json @@ -0,0 +1,14 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Conditions": { + "FailingCondition": { + "Fn::Equals": [{"key": [1, 2]}, 0] + } + }, + "Resources": { + "TestResource": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithLangXIntrinsicAndUnresolvablePseudoParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithLangXIntrinsicAndUnresolvablePseudoParam.json new file mode 100644 index 00000000000..ce48812f94b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithLangXIntrinsicAndUnresolvablePseudoParam.json @@ -0,0 +1,13 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { "Ref": "AWS::StackName" }, + "DelaySeconds": { "Fn::Length" : ["a", "b"] } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithMappingMatchNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithMappingMatchNull.json new file mode 100644 index 00000000000..b37c92a96af --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithMappingMatchNull.json @@ -0,0 +1,28 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Mappings" : { + "NameMap" : { + "one" : { + "Name": "Name1" + }, + "two" : { + "Name": null + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap" : [ + "NameMap", + "two", + "Name" + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedCircularConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedCircularConditions.json new file mode 100644 index 00000000000..0f7cdfc527f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedCircularConditions.json @@ -0,0 +1,53 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Conditions": { + "A": { + "Fn::Equals": ["a", "a"] + }, + "B": { + "Fn::And": [ + { + "Fn::Equals": [ + "b", + "b" + ] + }, + {"Condition": "A"}, + {"Condition": "E"} + ] + }, + "C": { + "Fn::Or": [ + { + "Fn::Equals": ["C", "B"] + }, + { + "Fn::Not": [ + {"Condition": "A"} + ] + } + ] + }, + "D": { + "Fn::Not": [{"Condition": "B"}] + }, + "E": { + "Fn::And": [ + {"Condition": "C"}, + {"Fn::Not": [ + { + "Fn::Not": [ + {"Condition": "D"} + ] + } + ]} + ] + } + }, + "Resources": { + "waitConditionHandles": { + "Condition": "B", + "Type" : "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefConditions.json new file mode 100644 index 00000000000..317b5f9c844 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefConditions.json @@ -0,0 +1,37 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Parameters": { + "stage": { + "Type": "String", + "Default": "prod" + }, + "queueName": { + "Type": "String", + "Default": "hello" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ + {"Ref": "stage"}, + "prod" + ] + } + }, + "Resources": { + "Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Ref": { + "Fn::If": [ + "IsProd", + "queueName", + {"Ref": "AWS::NoValue"} + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefNoValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefNoValue.json new file mode 100644 index 00000000000..a2bbae7b946 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNestedRefNoValue.json @@ -0,0 +1,13 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": { + "Ref": { "Ref": "AWS::NoValue" } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNoMappingMatch.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNoMappingMatch.json new file mode 100644 index 00000000000..55ca2ba6e4f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNoMappingMatch.json @@ -0,0 +1,28 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Mappings" : { + "NameMap" : { + "one" : { + "Name": "Name1" + }, + "two" : { + "Name": "Name2" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap" : [ + "NameMap", + "NonExisting", + "Name" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNonresolvableConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNonresolvableConditions.json new file mode 100644 index 00000000000..71ecad23593 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNonresolvableConditions.json @@ -0,0 +1,26 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Conditions": { + "IsIAD": { + "Fn::Equals": [ + "IAD", + "IAD" + ] + }, + "CorrectRegionAndStage": { + "Fn::And": [ + {"Condition": "Unresolvable"}, + {"Condition": "IsIAD"} + ] + } + }, + "Resources": { + "waitConditionHandles": { + "Condition": "CorrectRegionAndStage", + "Type" : "AWS::CloudFormation::WaitConditionHandle", + "Properties" : { + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullInput.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullInput.json new file mode 100644 index 00000000000..86f4b976621 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullInput.json @@ -0,0 +1,14 @@ +{ + "Resources": { + "DNSDelegatorLambda": { + "Properties": { + "AutoPublishAlias": null, + "DeploymentPreference": null, + "MemorySize": 512, + "Runtime": "nodejs16.x", + "Timeout": 300 + }, + "Type": "AWS::Serverless::Function" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullOutput.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullOutput.json new file mode 100644 index 00000000000..8323a6b6e0c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullOutput.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "waitConditionHandle": { + "Type" : "AWS::CloudFormation::WaitConditionHandle", + "Properties" : { + } + } + }, + "Outputs": { + "some-output": null + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullResources.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullResources.json new file mode 100644 index 00000000000..cd8f88b7834 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithNullResources.json @@ -0,0 +1,5 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensionsDev", + "Resources": null +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithOutputsAndNoValueProperty.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithOutputsAndNoValueProperty.json new file mode 100644 index 00000000000..1eac71ec2f1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithOutputsAndNoValueProperty.json @@ -0,0 +1,14 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "MyWaitHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + } + }, + "Outputs": { + "Result": { + "Description": "Description exists, but no Outputs" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithRedundantFnSub.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithRedundantFnSub.json new file mode 100644 index 00000000000..1c3ac2545bf --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithRedundantFnSub.json @@ -0,0 +1,20 @@ +{ + "Transform": "AWS::LanguageExtensions", + "Parameters" : { + "BucketName" : { + "Type" : "String" + } + }, + "Resources" : { + "S3Bucket" : { + "DeletionPolicy": "Delete", + "Type" : "AWS::S3::Bucket", + "Properties" : { + "BucketName" : {"Fn::Sub": "bucketname"}, + "VersioningConfiguration" : { + "Status" : "Enabled" + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutConditionSection.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutConditionSection.json new file mode 100644 index 00000000000..19f3a5f40b7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutConditionSection.json @@ -0,0 +1,13 @@ +{ + "Resources": { + "DNSDelegatorLambda": { + "Condition": "SupportsDNSDelegator", + "Properties": { + "MemorySize": 512, + "Runtime": "nodejs16.x", + "Timeout": 300 + }, + "Type": "AWS::Serverless::Function" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutReferencedInConditionSection.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutReferencedInConditionSection.json new file mode 100644 index 00000000000..e9208d9db5c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourceConditionWithoutReferencedInConditionSection.json @@ -0,0 +1,21 @@ +{ + "Conditions": { + "SupportsDNS": { + "Fn::Equals": [ + "true", + "false" + ] + } + }, + "Resources": { + "DNSDelegatorLambda": { + "Condition": "SupportsDNSDelegator", + "Properties": { + "MemorySize": 512, + "Runtime": "nodejs16.x", + "Timeout": 300 + }, + "Type": "AWS::Serverless::Function" + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourcesDefinedAsNull.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourcesDefinedAsNull.json new file mode 100644 index 00000000000..126345154ce --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithResourcesDefinedAsNull.json @@ -0,0 +1,11 @@ +{ + "Resources": { + "Cluster": { + "Type": "AWS::ECS::Cluster", + "Properties": { + "ClusterName": "cluster" + } + }, + "Queue": null + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithSelfReferenceCircularConditions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithSelfReferenceCircularConditions.json new file mode 100644 index 00000000000..baf91d06e4d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithSelfReferenceCircularConditions.json @@ -0,0 +1,28 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Conditions": { + "A": { + "Fn::Equals": ["a", "a"] + }, + "F": { + "Fn::And": [ + {"Condition": "A"}, + { + "Fn::Not": [ + { + "Fn::Not": [ + {"Condition": "F"} + ] + } + ] + } + ] + } + }, + "Resources": { + "waitConditionHandles": { + "Condition": "ConditionA", + "Type" : "AWS::CloudFormation::WaitConditionHandle" + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithStringResource.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithStringResource.json new file mode 100644 index 00000000000..0d48662a42e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithStringResource.json @@ -0,0 +1,7 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "LogicalId": "" + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithUnresolvedFnSelectRef.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithUnresolvedFnSelectRef.json new file mode 100644 index 00000000000..7284aa702fd --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/templateWithUnresolvedFnSelectRef.json @@ -0,0 +1,33 @@ +{ + "Resources": { + "RecordSetB": { + "Type": "AWS::Route53::RecordSet", + "DependsOn": "RedirectCloudFrontDistribution", + "Properties": { + "AliasTarget": { + "DNSName": {"Fn::GetAtt": ["RedirectCloudFrontDistribution", "DomainName"]}, + "HostedZoneId": "Z2FDTNDATAQYW2" + }, + "Comment": { + "Fn::Join": [ + "", + [ + "Record set for the ", + { + "Fn::Select": [0, ["0"]] + }, + " redirect" + ] + ] + }, + "HostedZoneId": { + "Ref": "HostedZoneId" + }, + "Name": { + "Fn::Select": [1, {"Ref": "RedirectHostnames"}] + }, + "Type": "A" + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonFnSplitDirectlyUnderToJson.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonFnSplitDirectlyUnderToJson.json new file mode 100644 index 00000000000..72047b198fa --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonFnSplitDirectlyUnderToJson.json @@ -0,0 +1,21 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Zone": { + "Type": "AWS::Route53::HostedZone", + "Properties": { + "Name": "banana.com" + } + } + }, + "Outputs": { + "Output": { + "Value": { + "Fn::ToJsonString": { + "Fn::Split": ["/", "a/b/c"] + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunction.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunction.json new file mode 100644 index 00000000000..0df419753ce --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunction.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "and": { "Fn::And": [ + {"Fn::Equals": ["Pass", {"Ref": "PasswordParameter"}]}, + {"Condition": "IsProd"} + ] } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunctionArray.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunctionArray.json new file mode 100644 index 00000000000..1f3f57d1d23 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvableIntrinsicFunctionArray.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": [{ + "and": { "Fn::And": [ + {"Fn::Equals": ["Pass", {"Ref": "PasswordParameter"}]}, + {"Condition": "IsProd"} + ] } + }] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParam.json new file mode 100644 index 00000000000..2545a6e3945 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParam.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "notificationARN": { "Ref": "AWS::NotificationARNs" } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParamArray.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParamArray.json new file mode 100644 index 00000000000..2c96ca2e98e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToNonResolvablePseudoParamArray.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": [{ + "notificationARN": { "Ref": "AWS::NotificationARNs" } + }] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValue.json new file mode 100644 index 00000000000..2eb0453d0e4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValue.json @@ -0,0 +1,24 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "password": { "Ref": "PasswordParameter" }, + "secretToken": "123" + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValueArray.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValueArray.json new file mode 100644 index 00000000000..797c17d29a4 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithDefaultValueArray.json @@ -0,0 +1,30 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": [ + { + "password": { "Ref": "PasswordParameter" }, + "secretToken": "123" + }, + { + "password": { "Ref": "AWS::NoValue" }, + "secretToken": "456" + } + ] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctions.json new file mode 100644 index 00000000000..953a45f05b2 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctions.json @@ -0,0 +1,55 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "Value" + }, + "us-west-1" : { + "Key": "Value" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "base64": { "Fn::Base64": "SomeValueToEncode" }, + "importValue": { "Fn::ImportValue": {"Fn::Sub": "${PasswordParameter}-SubnetID" } }, + "getatt": { "Fn::GetAtt" : ["Bucket", "Arn"] }, + "noValue": { "Ref": "AWS::NoValue" } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctionsArray.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctionsArray.json new file mode 100644 index 00000000000..bc32b30570a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithNonResolvableFunctionsArray.json @@ -0,0 +1,55 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "Value" + }, + "us-west-1" : { + "Key": "Value" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": [{ + "base64": { "Fn::Base64": "SomeValueToEncode" }, + "importValue": { "Fn::ImportValue": {"Fn::Sub": "${PasswordParameter}-SubnetID" } }, + "getatt": { "Fn::GetAtt" : ["Bucket", "Arn"] }, + "noValue": { "Ref": "AWS::NoValue" } + }] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctions.json new file mode 100644 index 00000000000..3b3e104345e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctions.json @@ -0,0 +1,84 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "Value" + }, + "us-west-1" : { + "Key": "Value" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "string": "123", + "param": { "Ref": "PasswordParameter" }, + "partition": { "Ref": "AWS::Partition" }, + "accountId": { "Ref": "AWS::AccountId" }, + "region": { "Ref": "AWS::Region" }, + "map": { + "Fn::FindInMap" : [ + "RegionMap", + { "Ref" : "Region" }, + "Key" + ] + }, + "if": { "Fn::If": ["IsProd", "Retain", "Delete" ] }, + "noValue": { "Ref": "AWS::NoValue" }, + "sub": { + "Fn::Sub" : [ + "${Policy}", + { + "Policy": { + "Ref": "PasswordParameter" + } + } + ] + }, + "join": { "Fn::Join": [ ":", [ "a", { "Ref": "PasswordParameter" }, "c" ] ] }, + "select": { "Fn::Select" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] }, + "length": { "Fn::Length" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] }, + "toJsonString": { + "Fn::ToJsonString": { + "string": "123", + "param": { "Ref": "PasswordParameter" } + } + } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctionsArray.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctionsArray.json new file mode 100644 index 00000000000..ed1d98103e7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithResolvableFunctionsArray.json @@ -0,0 +1,85 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Region": { + "Type": "String", + "Default": "us-west-1" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "Value" + }, + "us-west-1" : { + "Key": "Value" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": [{ + "base64": { "Fn::Base64": "SomeValueToEncode" }, + "string": "123", + "param": { "Ref": "PasswordParameter" }, + "partition": { "Ref": "AWS::Partition" }, + "accountId": { "Ref": "AWS::AccountId" }, + "region": { "Ref": "AWS::Region" }, + "map": { + "Fn::FindInMap" : [ + "RegionMap", + { "Ref" : "Region" }, + "Key" + ] + }, + "if": { "Fn::If": ["IsProd", "Retain", "Delete" ] }, + "noValue": { "Ref": "AWS::NoValue" }, + "sub": { + "Fn::Sub" : [ + "${Policy}", + { + "Policy": { + "Ref": "PasswordParameter" + } + } + ] + }, + "join": { "Fn::Join": [ ":", [ "a", { "Ref": "PasswordParameter" }, "c" ] ] }, + "select": { "Fn::Select" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] }, + "length": { "Fn::Length" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] }, + "toJsonString": { + "Fn::ToJsonString": { + "string": "123", + "param": { "Ref": "PasswordParameter" } + } + } + }] + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithoutDefaultValue.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithoutDefaultValue.json new file mode 100644 index 00000000000..74909db4019 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringReferringToParamWithoutDefaultValue.json @@ -0,0 +1,29 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String" + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "password": { "Ref": "PasswordParameter" }, + "secretToken": "123", + "additional": [ + "item1", + { "Ref": "PasswordParameter" }, + "item3", + { "Ref": "AWS::NoValue" } + ] + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithCidrInSelect.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithCidrInSelect.json new file mode 100644 index 00000000000..8f69d5e13ea --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithCidrInSelect.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "cidr": { "Fn::Select" : [ "1", { "Fn::Cidr": [ "192.168.0.0/24", "6", "5" ] } ] } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithInvalidLayout.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithInvalidLayout.json new file mode 100644 index 00000000000..dbc7d30033d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithInvalidLayout.json @@ -0,0 +1,15 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": "" + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSimpleTags.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSimpleTags.json new file mode 100644 index 00000000000..bb91fc72b0a --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSimpleTags.json @@ -0,0 +1,18 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "MyEC2SecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "LOOK_AT_TAGS", + "Tags": [ + { + "Key": "keyname1", + "Value": "value" + } + ] + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatDoesNotResolve.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatDoesNotResolve.json new file mode 100644 index 00000000000..16ad686ffab --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatDoesNotResolve.json @@ -0,0 +1,20 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "split": { "Fn::Split": [ + "|", + { "Ref": "UndefinedParam" } + ] } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatResolves.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatResolves.json new file mode 100644 index 00000000000..6dae2ea7b1f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithSplitThatResolves.json @@ -0,0 +1,35 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "PasswordParameter": { + "Type": "String", + "Default": "SecretPa$$" + }, + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "split": { "Fn::Split": [ + "|", + "a|b|c" + ] } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithStackNamePseudoParam.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithStackNamePseudoParam.json new file mode 100644 index 00000000000..dd09d5344d0 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithStackNamePseudoParam.json @@ -0,0 +1,17 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Resources": { + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "stackName": { "Ref": "AWS::StackName" } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithinIntrinsicFunctions.json b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithinIntrinsicFunctions.json new file mode 100644 index 00000000000..fd69162490c --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/templates/toJsonStringWithinIntrinsicFunctions.json @@ -0,0 +1,69 @@ +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Transform": "LanguageExtensionsDev", + "Parameters": { + "Stage": { + "Type": "String", + "Default": "prod" + } + }, + "Conditions": { + "IsProd": { + "Fn::Equals": [ { "Ref": "Stage" }, "prod" ] + } + }, + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "Key": "Value" + }, + "us-west-1" : { + "Key": "Value" + }, + "{}": { + "Key": "Value" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + } + }, + "CloudFormationCreatedSecret": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Name": "MySecret", + "SecretString": { + "Fn::ToJsonString": { + "map": { + "Fn::FindInMap" : [ + "RegionMap", + { "Fn::ToJsonString": {} }, + "Key" + ] + }, + "if": { "Fn::If": ["IsProd", { "Fn::ToJsonString": { "key": "value" } }, "Delete" ] }, + "base64": { "Fn::Base64": { "Fn::ToJsonString": { "key": { "Fn::Base64": "SomeValueToEncode" } } } }, + "join": { "Fn::Join": [ ":", [ "a", { "Fn::ToJsonString": { "key": "value" } }, "c" ] ] }, + "select": { "Fn::Select" : [ "1", [ "apples", { "Fn::ToJsonString": { "key": "value" } }, "oranges", "mangoes" ] ] }, + "split": { "Fn::Split": [ ",", { "Fn::ToJsonString": { "key": "value", "key2": "value2" } } ] }, + "sub": { + "Fn::Sub" : [ + "${jsonString}", + { + "jsonString": { + "Fn::ToJsonString": { "key": "value" } + } + } + ] + }, + "length": { "Fn::Length" : [ { "Fn::ToJsonString": { "key": "value" } }, [ "apples", "grapes", "oranges", "mangoes" ] ] } + } + } + } + } + } +} diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py b/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py new file mode 100644 index 00000000000..c38b5a5fdeb --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py @@ -0,0 +1,548 @@ +""" +Compatibility tests using Kotlin test templates. + +This module runs the Python implementation against the same test templates +used by the Kotlin implementation to verify behavioral compatibility. + +The test templates are located in: + tests/compatibility/templates/ + +Test patterns: + - Files ending with 'Resolved.json' or 'Expected.json' are expected outputs + - Files without these suffixes are inputs + - Some inputs should produce errors (no expected file exists) + - Templates with $RESOURCE_ATTRIBUTE placeholder need replacement with + DeletionPolicy or UpdateReplacePolicy before testing + +Compatibility Notes: + 1. Lambda macro request format (2 templates): + Some templates use the Lambda macro request format with 'fragment', + 'accountId', 'transformId' fields. This format is specific to the + Kotlin Lambda handler and is not supported by the Python library + (which processes templates directly). These templates are tested + separately to verify they raise InvalidTemplateException. + + 2. All other templates are fully compatible with the Kotlin implementation. +""" + +import json +import pytest +from pathlib import Path +from typing import Any, Dict, Optional, Tuple, List + +from samcli.lib.cfn_language_extensions.api import process_template +from samcli.lib.cfn_language_extensions.models import ResolutionMode, PseudoParameterValues +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +# Path to test templates +KOTLIN_TEMPLATES_DIR = Path(__file__).parent / "templates" + + +def load_json_template(path: Path) -> Dict[str, Any]: + """Load a JSON template file.""" + with open(path, "r") as f: + return dict(json.load(f)) + + +def load_json_template_with_placeholder( + path: Path, placeholder: str = "$RESOURCE_ATTRIBUTE", replacement: str = "DeletionPolicy" +) -> Dict[str, Any]: + """Load a JSON template file, replacing placeholders.""" + with open(path, "r") as f: + content = f.read() + content = content.replace(placeholder, replacement) + return dict(json.loads(content)) + + +def find_test_pairs(directory: Path) -> List[Tuple[Path, Optional[Path]]]: + """Find input/expected output pairs in a directory.""" + pairs: List[Tuple[Path, Optional[Path]]] = [] + if not directory.exists(): + return pairs + + for file_path in sorted(directory.glob("*.json")): + name = file_path.stem + if name.endswith("Expected") or name.endswith("Resolved"): + continue + expected_path = directory / f"{name}Expected.json" + resolved_path = directory / f"{name}Resolved.json" + if expected_path.exists(): + pairs.append((file_path, expected_path)) + elif resolved_path.exists(): + pairs.append((file_path, resolved_path)) + else: + pairs.append((file_path, None)) + return pairs + + +def normalize_template(template: Any) -> Any: + """Normalize a template for comparison.""" + if isinstance(template, dict): + return {k: normalize_template(v) for k, v in sorted(template.items())} + elif isinstance(template, list): + return [normalize_template(item) for item in template] + elif isinstance(template, float): + if template == int(template): + return int(template) + return round(template, 10) + return template + + +# ============================================================================= +# ForEach Parametrized Tests - All passing +# ============================================================================= + + +def get_foreach_test_cases(section: str) -> List[Tuple[str, Path, Path]]: + """Get all ForEach test cases for a section.""" + templates_dir = KOTLIN_TEMPLATES_DIR / "forEach" / section + if not templates_dir.exists(): + return [] + test_cases = [] + for input_path, expected_path in find_test_pairs(templates_dir): + if expected_path is not None: + test_cases.append((input_path.stem, input_path, expected_path)) + return test_cases + + +@pytest.mark.parametrize( + "test_name,input_path,expected_path", + get_foreach_test_cases("outputs"), + ids=lambda x: x if isinstance(x, str) else None, +) +def test_foreach_outputs(test_name: str, input_path: Path, expected_path: Path): + """Test ForEach expansion in Outputs section.""" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + expected_template = load_json_template(expected_path) + result = process_template(input_template) + assert normalize_template(result) == normalize_template(expected_template) + + +@pytest.mark.parametrize( + "test_name,input_path,expected_path", + get_foreach_test_cases("resources"), + ids=lambda x: x if isinstance(x, str) else None, +) +def test_foreach_resources(test_name: str, input_path: Path, expected_path: Path): + """Test ForEach expansion in Resources section.""" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + expected_template = load_json_template(expected_path) + result = process_template(input_template) + assert normalize_template(result) == normalize_template(expected_template) + + +@pytest.mark.parametrize( + "test_name,input_path,expected_path", + get_foreach_test_cases("conditions"), + ids=lambda x: x if isinstance(x, str) else None, +) +def test_foreach_conditions(test_name: str, input_path: Path, expected_path: Path): + """Test ForEach expansion in Conditions section.""" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + expected_template = load_json_template(expected_path) + result = process_template(input_template) + assert normalize_template(result) == normalize_template(expected_template) + + +# ============================================================================= +# Root Templates - Error Cases (should raise InvalidTemplateException) +# ============================================================================= + +# Templates that correctly raise errors +ERROR_TEMPLATES_PASSING = [ + "fnIfReferencingNonExistingCondition", + "templateWithNullResources", + "templateWithResourcesDefinedAsNull", + "templateWithNullOutput", + "outputWithAwsNoValue", + "templateWithNestedRefNoValue", + "templateFnInMapInWhenTrueConditionInOutputWithInvalidParam", + "templateFnInMapInWhenTrueConditionInResourceWithInvalidParam", + "toJsonStringWithInvalidLayout", + "templateWithFnLengthAndIncorrectLayout", + "templateWithInvalidFnSelectIndex", + "templateWithNoMappingMatch", + "toJsonStringWithSplitThatDoesNotResolve", + "templateWithFnMapInCondition", + "templateWithStringResource", + "fnSplitWithEmptySpliter", + "templateWithNestedCircularConditions", + "templateWithSelfReferenceCircularConditions", + "templateWithNonresolvableConditions", + "templateWithResourceConditionWithoutConditionSection", + "templateWithResourceConditionWithoutReferencedInConditionSection", + "templateWithMappingMatchNull", + "outputWithNullSub", + "templateWithInvalidConditionRef", + "templateWithInvalidIntrinsicFunctionType", + "toJsonStringReferringToNonResolvablePseudoParam", + "toJsonStringReferringToNonResolvablePseudoParamArray", + "toJsonStringReferringToNonResolvableIntrinsicFunction", + "toJsonStringReferringToNonResolvableIntrinsicFunctionArray", + # FindInMap templates that should throw errors + "fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString", + "fnFindInMapWithMapNameNotResolveToString", + "fnFindInMapWithReferenceToIncorrectParameterType", + "fnFindInMapWithUnsupportedFunctionFnGetAtt", + "fnFindInMapWithUnsupportedFunctionFnRef", + "fnFindInMapWithUnsupportedFunctionInMapName", + "templateFnInMapInWhenFalseConditionInResourceWithInvalidStringPath", +] + +# Templates that should error but have compatibility issues +ERROR_TEMPLATES_XFAIL: List[str] = [ + # No more xfail templates - all handled by placeholder replacement tests below +] + +# Templates that use $RESOURCE_ATTRIBUTE placeholder and should raise errors +ERROR_TEMPLATES_WITH_PLACEHOLDER = [ + "resourceAttributesWithWrongValueList", + "resourceAttributesWithAwsNoValue", +] + + +@pytest.mark.parametrize("template_name", ERROR_TEMPLATES_PASSING) +def test_error_templates_passing(template_name: str): + """Test templates that should raise InvalidTemplateException.""" + input_path = KOTLIN_TEMPLATES_DIR / f"{template_name}.json" + if not input_path.exists(): + pytest.skip(f"Template {template_name} not available") + input_template = load_json_template(input_path) + with pytest.raises(InvalidTemplateException): + process_template(input_template) + + +@pytest.mark.parametrize("template_name", ERROR_TEMPLATES_WITH_PLACEHOLDER) +def test_error_templates_with_placeholder(template_name: str): + """Test templates with $RESOURCE_ATTRIBUTE placeholder that should raise errors.""" + input_path = KOTLIN_TEMPLATES_DIR / f"{template_name}.json" + if not input_path.exists(): + pytest.skip(f"Template {template_name} not available") + # Replace placeholder with DeletionPolicy (Kotlin tests do this) + input_template = load_json_template_with_placeholder(input_path) + with pytest.raises(InvalidTemplateException): + process_template(input_template) + + +# ============================================================================= +# Root Templates - Success Cases (should process without errors) +# ============================================================================= + +# Templates that correctly process without errors +SUCCESS_TEMPLATES_PASSING = [ + "conditionWithNull", + "fnFindInMapSelectDefaultValueList", + "fnFindInMapWithDefaultValue", + "fnFindInMapWithDefaultValueListAsIs", + "fnFindInMapWithFnGetAttInDefaultValue", + "fnFindInMapWithFnRefInDefaultValue", + "fnFindInMapWithIntrinsic", + "fnFindInMapWithUnsupportedFunctionFnSub", + "fnIfOutputBug", + "fnIfWithUnresolvableInFalseBranch", + "fnSubWithParameter", + "fnSubWithReference", + "getAttOfIntrinsicFunction", + "getAttOfUnresolvableIntrinsicFunctionInAttrName", + "getAttOfUnresolvableIntrinsicFunctionInLogicalId", + "noEchoParameter", + "noResourceAttribute", + "noResourceAttributeWithAwsStackId", + "noResourceAttributeWithAwsStackName", + "propertiesWithNull", + "refOfIntrinsicFunction", + "refOfIntrinsicFunctionWithParameterValueAssigned", + "refOfUnresolvableIntrinsicFunction", + "resourceAttributesReferringToParam", + "resourceAttributesReferringToString", + "resourceAttributesWithAccountIdPseudoParam", + "resourceAttributesWithCondition", + "resourceAttributesWithFnGetAtt", + "resourceAttributesWithFnSubWithRightValue", + "resourceAttributesWithMap", + "resourceAttributesWithReferenceToOtherResource", + "resourceAttributesWithRegionPseudoParam", + "resourceWithBase64String", + "templateComparingStringWithBoolean", + "templateComparingStringWithNumber", + "templateFindInMapWhenFalseConditionInResourceWithDefaultValue", + "templateFindInMapWithDifferentConditionsPerResources", + "templateFindInMapWithInvalidRef", # Has false condition, so succeeds with partial resolution + "templateFindInMapWithinFnIfDiscardedWhenFalseCondition", + "templateFindInMapWithinFnIfResolvableAsFalseConditionWithDefaultValue", + "templateWithDifferentTypesOfParameters", + "templateWithFindInMapDefaultValue", + "templateWithFnJoinWithNoValue", + "templateWithFnLengthInConditions", + "templateWithFnLengthReferringToFnBase64", + "templateWithFnLengthReferringToFnFindInMap", + "templateWithFnLengthReferringToFnIf", + "templateWithFnLengthReferringToFnJoin", + "templateWithFnLengthReferringToFnSelect", + "templateWithFnLengthReferringToFnSplit", + "templateWithFnLengthReferringToFnSub", + "templateWithFnLengthReferringToResolvableFunctions", + "templateWithFnLengthSimple", + "templateWithFnLengthWithRef", + "templateWithFnLengthWithRefToCDLInConditions", + "templateWithIntrinsicFunctionsInOutput", + "templateWithLangXIntrinsicAndUnresolvablePseudoParam", + "templateWithNestedRefConditions", + "templateWithNullInput", + "templateWithOutputsAndNoValueProperty", + "templateWithRedundantFnSub", + "templateWithUnresolvedFnSelectRef", + "toJsonFnSplitDirectlyUnderToJson", + "toJsonStringReferringToParamWithDefaultValue", + "toJsonStringReferringToParamWithDefaultValueArray", + "toJsonStringReferringToParamWithNonResolvableFunctions", + "toJsonStringReferringToParamWithNonResolvableFunctionsArray", + "toJsonStringReferringToParamWithoutDefaultValue", + "toJsonStringReferringToParamWithResolvableFunctions", + "toJsonStringReferringToParamWithResolvableFunctionsArray", + "toJsonStringWithCidrInSelect", + "toJsonStringWithinIntrinsicFunctions", + "toJsonStringWithSimpleTags", + "toJsonStringWithSplitThatResolves", + "toJsonStringWithStackNamePseudoParam", +] + +# Templates with known compatibility issues +# These templates use $RESOURCE_ATTRIBUTE placeholder that needs to be replaced +# or have special Lambda transform request format +SUCCESS_TEMPLATES_XFAIL: List[str] = [ + # No xfail templates - all compatibility issues resolved +] + +# Templates that are intentionally not supported by the Python library +# These use the Lambda macro request format (fragment, accountId, transformId, etc.) +# which is specific to the Kotlin Lambda handler, not the template processing logic +LAMBDA_FORMAT_TEMPLATES = [ + "templateWithIntrinsicFunctionsAndDefaultValue", + "templateWithIntrinsicFunctionsWithoutDefaultValue", +] + + +@pytest.mark.parametrize("template_name", SUCCESS_TEMPLATES_PASSING) +def test_success_templates_passing(template_name: str): + """Test templates that should process successfully.""" + input_path = KOTLIN_TEMPLATES_DIR / f"{template_name}.json" + if not input_path.exists(): + pytest.skip(f"Template {template_name} not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert isinstance(result, dict) + + +@pytest.mark.parametrize("template_name", LAMBDA_FORMAT_TEMPLATES) +def test_lambda_format_templates_not_supported(template_name: str): + """Test that Lambda macro format templates are not supported. + + These templates use the Lambda macro request format (fragment, accountId, + transformId, templateParameterValues) which is specific to the Kotlin + Lambda handler. The Python library processes templates directly and + doesn't need to support this format. + """ + input_path = KOTLIN_TEMPLATES_DIR / f"{template_name}.json" + if not input_path.exists(): + pytest.skip(f"Template {template_name} not available") + input_template = load_json_template(input_path) + # These templates don't have a Resources section at the top level + # (it's nested under 'fragment'), so they should fail validation + with pytest.raises(InvalidTemplateException): + process_template(input_template) + + +# ============================================================================= +# Templates with Expected Output +# ============================================================================= + + +def test_template_with_intrinsic_in_policy_document(): + """Test template with intrinsic function in PolicyDocument resolves correctly.""" + input_path = KOTLIN_TEMPLATES_DIR / "templateWithIntrinsicInPolicyDocument.json" + expected_path = KOTLIN_TEMPLATES_DIR / "templateWithIntrinsicInPolicyDocumentResolved.json" + + if not input_path.exists(): + pytest.skip("Template not available") + + input_template = load_json_template(input_path) + expected_template = load_json_template(expected_path) + result = process_template(input_template) + + # Compare Resources section + assert normalize_template(result["Resources"]) == normalize_template(expected_template["Resources"]) + + +def test_template_with_decimal_numbers(): + """Test template with decimal numbers resolved without truncation. + + Note: The Kotlin expected output file has Outputs, AWSTemplateFormatVersion, + and Hooks nested inside the Resources section. This appears to be a + serialization artifact from the aws.cfn library used by Kotlin. The Python + implementation correctly keeps these at the top level. We compare only the + NullResource which is the actual resource being tested for decimal number + handling. + """ + input_path = KOTLIN_TEMPLATES_DIR / "templateWithDecimalNumbers.json" + expected_path = KOTLIN_TEMPLATES_DIR / "templateWithDecimalNumbersResolved.json" + + if not input_path.exists(): + pytest.skip("Template not available") + + input_template = load_json_template(input_path) + expected_template = load_json_template(expected_path) + result = process_template(input_template) + + # Compare only the NullResource - the expected file has extra sections + # (Outputs, Hooks, AWSTemplateFormatVersion) nested inside Resources + # which appears to be a serialization artifact from the Kotlin aws.cfn library + assert normalize_template(result["Resources"]["NullResource"]) == normalize_template( + expected_template["Resources"]["NullResource"] + ) + + # Also verify decimal numbers are preserved correctly + assert result["Resources"]["NullResource"]["Metadata"]["EmbeddedValue"] == 99999999999.99 + assert result["Resources"]["NullResource"]["Metadata"]["ParameterReference"] == 99999999999.99 + + +# ============================================================================= +# Specific Behavior Tests +# ============================================================================= + + +class TestFnLength: + """Test Fn::Length specific behaviors.""" + + def test_fn_length_simple(self): + """Test simple Fn::Length resolves to list length.""" + input_path = KOTLIN_TEMPLATES_DIR / "templateWithFnLengthSimple.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert result["Resources"]["Queue"]["Properties"]["DelaySeconds"] == 3 + + def test_fn_length_with_ref_parameter(self): + """Test Fn::Length with Ref to parameter.""" + input_path = KOTLIN_TEMPLATES_DIR / "templateWithFnLengthWithRef.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template, parameter_values={"queues": ["a", "b", "c"]}) + assert result["Resources"]["Queue"]["Properties"]["DelaySeconds"] == 3 + + +class TestFnToJsonString: + """Test Fn::ToJsonString specific behaviors.""" + + def test_to_json_string_with_parameter_values(self): + """Test Fn::ToJsonString with parameter values.""" + input_path = KOTLIN_TEMPLATES_DIR / "toJsonStringReferringToParamWithoutDefaultValue.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template, parameter_values={"PasswordParameter": "Pa$$word"}) + secret_string = result["Resources"]["CloudFormationCreatedSecret"]["Properties"]["SecretString"] + assert "Pa$$word" in secret_string + + def test_to_json_string_with_simple_tags(self): + """Test Fn::ToJsonString with simple tags.""" + input_path = KOTLIN_TEMPLATES_DIR / "toJsonStringWithSimpleTags.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert "Resources" in result + + +class TestFnFindInMap: + """Test Fn::FindInMap specific behaviors.""" + + def test_find_in_map_with_default_value(self): + """Test Fn::FindInMap with DefaultValue.""" + input_path = KOTLIN_TEMPLATES_DIR / "fnFindInMapWithDefaultValue.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert "Resources" in result + + def test_find_in_map_with_intrinsic(self): + """Test Fn::FindInMap with intrinsic function keys.""" + input_path = KOTLIN_TEMPLATES_DIR / "fnFindInMapWithIntrinsic.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert "Resources" in result + + +class TestConditions: + """Test condition-related behaviors.""" + + def test_condition_with_null(self): + """Test condition handling with null values.""" + input_path = KOTLIN_TEMPLATES_DIR / "conditionWithNull.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + assert "Resources" in result + + +class TestResourceAttributes: + """Test resource attribute behaviors.""" + + def test_resource_attributes_referring_to_param(self): + """Test resource attributes with parameter reference.""" + input_path = KOTLIN_TEMPLATES_DIR / "resourceAttributesReferringToParam.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template, parameter_values={"DeletionPolicyParam": "Retain"}) + assert "Resources" in result + + def test_base64_encoding(self): + """Test Fn::Base64 correctly encodes.""" + input_path = KOTLIN_TEMPLATES_DIR / "resourceWithBase64String.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template(input_template) + user_data = result["Resources"]["EksNodeLaunchTemplate"]["Properties"]["LaunchTemplateData"]["UserData"] + assert user_data == "ZWNobyAnSGVsbG8gV29ybGQn" + + def test_resource_attributes_referring_to_multiple_params(self): + """Test resource attributes with multiple parameter references.""" + input_path = KOTLIN_TEMPLATES_DIR / "resourceAttributesReferringToMultipleParams.json" + if not input_path.exists(): + pytest.skip("Template not available") + input_template = load_json_template(input_path) + result = process_template( + input_template, + parameter_values={"ResourceAttributeParameter1": "Retain", "ResourceAttributeParameter2": "Delete"}, + ) + assert result["Resources"]["WaitHandle1"]["DeletionPolicy"] == "Retain" + assert result["Resources"]["WaitHandle2"]["UpdateReplacePolicy"] == "Delete" + + def test_resource_attributes_referring_to_partition(self): + """Test resource attributes with partition reference.""" + input_path = KOTLIN_TEMPLATES_DIR / "resourceAttributesReferringToPartition.json" + if not input_path.exists(): + pytest.skip("Template not available") + # Replace $RESOURCE_ATTRIBUTE with DeletionPolicy + input_template = load_json_template_with_placeholder(input_path) + result = process_template( + input_template, pseudo_parameters=PseudoParameterValues(region="us-east-1", account_id="123456789012") + ) + assert "Resources" in result + # The DeletionPolicy should be resolved from the mapping + assert result["Resources"]["WaitHandle"]["DeletionPolicy"] == "Delete" diff --git a/tests/unit/lib/cfn_language_extensions/conftest.py b/tests/unit/lib/cfn_language_extensions/conftest.py new file mode 100644 index 00000000000..e4cab044e63 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/conftest.py @@ -0,0 +1,91 @@ +""" +Pytest configuration and shared fixtures for cfn-language-extensions tests. +""" + +import pytest + + +@pytest.fixture +def minimal_template(): + """A minimal valid CloudFormation template.""" + return { + "Resources": { + "MyResource": { + "Type": "AWS::SQS::Queue", + } + } + } + + +@pytest.fixture +def template_with_parameters(): + """A template with parameters for testing parameter resolution.""" + return { + "Parameters": { + "Environment": { + "Type": "String", + "Default": "dev", + }, + "InstanceCount": { + "Type": "Number", + "Default": 1, + }, + }, + "Resources": { + "MyResource": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": {"Ref": "Environment"}, + }, + } + }, + } + + +@pytest.fixture +def template_with_mappings(): + """A template with mappings for testing Fn::FindInMap.""" + return { + "Mappings": { + "RegionMap": { + "us-east-1": { + "AMI": "ami-12345678", + "InstanceType": "t2.micro", + }, + "us-west-2": { + "AMI": "ami-87654321", + "InstanceType": "t2.small", + }, + } + }, + "Resources": { + "MyResource": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}, + }, + } + }, + } + + +@pytest.fixture +def template_with_conditions(): + """A template with conditions for testing condition resolution.""" + return { + "Parameters": { + "Environment": { + "Type": "String", + "Default": "dev", + }, + }, + "Conditions": { + "IsProd": {"Fn::Equals": [{"Ref": "Environment"}, "prod"]}, + }, + "Resources": { + "MyResource": { + "Type": "AWS::SQS::Queue", + "Condition": "IsProd", + } + }, + } diff --git a/tests/unit/lib/cfn_language_extensions/test_api.py b/tests/unit/lib/cfn_language_extensions/test_api.py new file mode 100644 index 00000000000..38a94cfcd4e --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_api.py @@ -0,0 +1,1153 @@ +""" +Unit tests for the CloudFormation Language Extensions public API. + +This module tests the main process_template function and related API +components. + +Requirements tested: + - 12.1: process_template accepts a template dictionary and processing options + - 12.4: Support both JSON and YAML template input formats + - 12.5: Return the processed template as a Python dictionary +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions import ( + process_template, + create_default_pipeline, + create_default_intrinsic_resolver, + InvalidTemplateException, + ResolutionMode, + PseudoParameterValues, + TemplateProcessingContext, +) + + +class TestProcessTemplate: + """Tests for the process_template function.""" + + def test_process_template_returns_dict(self): + """ + Requirement 12.5: process_template SHALL return the processed template + as a Python dictionary. + """ + template = {"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + + result = process_template(template) + + assert isinstance(result, dict) + assert "Resources" in result + assert "MyBucket" in result["Resources"] + + def test_process_template_accepts_template_dict(self): + """ + Requirement 12.1: process_template SHALL accept a template dictionary. + """ + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Test template", + "Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}, + } + + result = process_template(template) + + assert result["AWSTemplateFormatVersion"] == "2010-09-09" + assert result["Description"] == "Test template" + assert "MyQueue" in result["Resources"] + + def test_process_template_accepts_parameter_values(self): + """ + Requirement 12.1: process_template SHALL accept processing options + including parameter values. + """ + template = { + "Parameters": {"Environment": {"Type": "String", "Default": "dev"}}, + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": {"Ref": "Environment"}}} + }, + } + + result = process_template(template, parameter_values={"Environment": "prod"}) + + # The Ref should be resolved to the parameter value + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "prod" + + def test_process_template_accepts_pseudo_parameters(self): + """ + Requirement 12.1: process_template SHALL accept processing options + including pseudo-parameter values. + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::Sub": "bucket-${AWS::Region}"}}, + } + } + } + + pseudo_params = PseudoParameterValues(region="us-west-2", account_id="123456789012") + + result = process_template(template, pseudo_parameters=pseudo_params) + + # The pseudo-parameter should be resolved + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "bucket-us-west-2" + + def test_process_template_accepts_resolution_mode(self): + """ + Requirement 12.1: process_template SHALL accept processing options + including resolution mode. + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::GetAtt": ["OtherResource", "Arn"]}}, + } + } + } + + # In PARTIAL mode, Fn::GetAtt should be preserved + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + # Fn::GetAtt should be preserved in partial mode + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == {"Fn::GetAtt": ["OtherResource", "Arn"]} + + def test_process_template_does_not_modify_input(self): + """ + process_template SHALL NOT modify the input template dictionary. + """ + template = { + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["A", "B"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + } + } + + # Make a copy to compare later + original_template = { + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["A", "B"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + } + } + + process_template(template) + + # Original template should be unchanged + assert template == original_template + + def test_process_template_expands_foreach(self): + """ + process_template SHALL expand Fn::ForEach loops. + """ + template = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + + result = process_template(template) + + # ForEach should be expanded + assert "TopicAlerts" in result["Resources"] + assert "TopicNotifications" in result["Resources"] + assert "Fn::ForEach::Topics" not in result["Resources"] + + def test_process_template_resolves_fn_length(self): + """ + process_template SHALL resolve Fn::Length intrinsic function. + """ + template = { + "Resources": { + "MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"DelaySeconds": {"Fn::Length": [1, 2, 3, 4, 5]}}} + } + } + + result = process_template(template) + + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == 5 + + def test_process_template_resolves_fn_to_json_string(self): + """ + process_template SHALL resolve Fn::ToJsonString intrinsic function. + """ + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": {"Environment": {"Fn::ToJsonString": {"key": "value"}}}, + } + } + } + + result = process_template(template) + + assert result["Resources"]["MyFunction"]["Properties"]["Environment"] == '{"key":"value"}' + + def test_process_template_resolves_fn_find_in_map(self): + """ + process_template SHALL resolve Fn::FindInMap intrinsic function. + """ + template = { + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-12345"}, "us-west-2": {"AMI": "ami-67890"}}}, + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": {"ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}}, + } + }, + } + + result = process_template(template) + + assert result["Resources"]["MyInstance"]["Properties"]["ImageId"] == "ami-12345" + + def test_process_template_resolves_fn_find_in_map_with_default(self): + """ + process_template SHALL resolve Fn::FindInMap with DefaultValue. + """ + template = { + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-12345"}}}, + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "RegionMap", + "eu-west-1", # Not in map + "AMI", + {"DefaultValue": "ami-default"}, + ] + } + }, + } + }, + } + + result = process_template(template) + + assert result["Resources"]["MyInstance"]["Properties"]["ImageId"] == "ami-default" + + def test_process_template_validates_deletion_policy(self): + """ + process_template SHALL validate and resolve DeletionPolicy. + """ + template = { + "Parameters": {"Policy": {"Type": "String", "Default": "Retain"}}, + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "Policy"}}}, + } + + result = process_template(template) + + assert result["Resources"]["MyBucket"]["DeletionPolicy"] == "Retain" + + def test_process_template_validates_update_replace_policy(self): + """ + process_template SHALL validate and resolve UpdateReplacePolicy. + """ + template = { + "Parameters": {"Policy": {"Type": "String", "Default": "Delete"}}, + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "Policy"}}}, + } + + result = process_template(template) + + assert result["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Delete" + + def test_process_template_raises_for_null_resources(self): + """ + process_template SHALL raise InvalidTemplateException for null Resources. + """ + template = {"Resources": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + process_template(template) + + assert "The Resources section must not be null" in str(exc_info.value) + + def test_process_template_raises_for_invalid_foreach(self): + """ + process_template SHALL raise InvalidTemplateException for invalid ForEach. + """ + template = {"Resources": {"Fn::ForEach::Invalid": []}} # Empty list is invalid ForEach layout + + with pytest.raises(InvalidTemplateException) as exc_info: + process_template(template) + + assert "layout is incorrect" in str(exc_info.value) + + def test_process_template_with_nested_intrinsics(self): + """ + process_template SHALL resolve nested intrinsic functions. + """ + template = { + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": {"DelaySeconds": {"Fn::Length": {"Fn::Split": [",", "a,b,c"]}}}, + } + } + } + + result = process_template(template) + + # Fn::Split produces ["a", "b", "c"], Fn::Length returns 3 + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == 3 + + def test_process_template_with_fn_join(self): + """ + process_template SHALL resolve Fn::Join intrinsic function. + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::Join": ["-", ["my", "bucket", "name"]]}}, + } + } + } + + result = process_template(template) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "my-bucket-name" + + def test_process_template_with_fn_select(self): + """ + process_template SHALL resolve Fn::Select intrinsic function. + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::Select": [1, ["first", "second", "third"]]}}, + } + } + } + + result = process_template(template) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "second" + + def test_process_template_with_fn_base64(self): + """ + process_template SHALL resolve Fn::Base64 intrinsic function. + """ + template = { + "Resources": { + "MyInstance": {"Type": "AWS::EC2::Instance", "Properties": {"UserData": {"Fn::Base64": "Hello World"}}} + } + } + + result = process_template(template) + + import base64 + + expected = base64.b64encode(b"Hello World").decode("utf-8") + assert result["Resources"]["MyInstance"]["Properties"]["UserData"] == expected + + +class TestCreateDefaultPipeline: + """Tests for the create_default_pipeline function.""" + + def test_create_default_pipeline_returns_pipeline(self): + """ + create_default_pipeline SHALL return a ProcessingPipeline instance. + """ + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}) + + pipeline = create_default_pipeline(context) + + from samcli.lib.cfn_language_extensions.pipeline import ProcessingPipeline + + assert isinstance(pipeline, ProcessingPipeline) + + def test_create_default_pipeline_has_processors(self): + """ + create_default_pipeline SHALL include all standard processors. + """ + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}) + + pipeline = create_default_pipeline(context) + + # Should have at least 5 processors + assert len(pipeline.processors) >= 5 + + +class TestCreateDefaultIntrinsicResolver: + """Tests for the create_default_intrinsic_resolver function.""" + + def test_create_default_intrinsic_resolver_returns_resolver(self): + """ + create_default_intrinsic_resolver SHALL return an IntrinsicResolver. + """ + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}) + + resolver = create_default_intrinsic_resolver(context) + + from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicResolver + + assert isinstance(resolver, IntrinsicResolver) + + def test_create_default_intrinsic_resolver_has_resolvers(self): + """ + create_default_intrinsic_resolver SHALL register all standard resolvers. + """ + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}) + + resolver = create_default_intrinsic_resolver(context) + + # Should have multiple resolvers registered + assert len(resolver.resolvers) >= 10 + + +class TestProcessTemplateIntegration: + """Integration tests for process_template with complex templates.""" + + def test_complex_template_with_multiple_features(self): + """ + process_template SHALL handle templates with multiple language extensions. + """ + template = { + "Parameters": {"Environment": {"Type": "String", "Default": "dev"}}, + "Mappings": {"EnvConfig": {"dev": {"Retention": "7"}, "prod": {"Retention": "30"}}}, + "Resources": { + "Fn::ForEach::Queues": [ + "QueueName", + ["Orders", "Payments"], + { + "Queue${QueueName}": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": {"Fn::Sub": "${QueueName}-${Environment}"}, + "MessageRetentionPeriod": { + "Fn::FindInMap": ["EnvConfig", {"Ref": "Environment"}, "Retention"] + }, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # ForEach should be expanded + assert "QueueOrders" in result["Resources"] + assert "QueuePayments" in result["Resources"] + + # Fn::Sub should be resolved + assert result["Resources"]["QueueOrders"]["Properties"]["QueueName"] == "Orders-dev" + assert result["Resources"]["QueuePayments"]["Properties"]["QueueName"] == "Payments-dev" + + # Fn::FindInMap should be resolved + assert result["Resources"]["QueueOrders"]["Properties"]["MessageRetentionPeriod"] == "7" + + def test_template_with_conditions(self): + """ + process_template SHALL handle templates with conditions. + """ + template = { + "Parameters": {"CreateBucket": {"Type": "String", "Default": "true"}}, + "Conditions": {"ShouldCreateBucket": {"Fn::Equals": [{"Ref": "CreateBucket"}, "true"]}}, + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::If": ["ShouldCreateBucket", "my-bucket", "other-bucket"]}}, + } + }, + } + + result = process_template(template) + + # Fn::If should be resolved based on condition + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "my-bucket" + + +class TestLoadTemplateFromJson: + """Tests for the load_template_from_json function.""" + + def test_load_template_from_json_returns_dict(self, tmp_path): + """ + Requirement 12.4: load_template_from_json SHALL load a template from + a JSON file and return it as a dictionary. + """ + from samcli.lib.cfn_language_extensions import load_template_from_json + + # Create a test JSON file + template_content = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}, + } + json_file = tmp_path / "template.json" + import json + + json_file.write_text(json.dumps(template_content)) + + result = load_template_from_json(str(json_file)) + + assert isinstance(result, dict) + assert result["AWSTemplateFormatVersion"] == "2010-09-09" + assert "MyBucket" in result["Resources"] + + def test_load_template_from_json_raises_for_missing_file(self, tmp_path): + """ + load_template_from_json SHALL raise FileNotFoundError for missing files. + """ + from samcli.lib.cfn_language_extensions import load_template_from_json + + with pytest.raises(FileNotFoundError): + load_template_from_json(str(tmp_path / "nonexistent.json")) + + def test_load_template_from_json_raises_for_invalid_json(self, tmp_path): + """ + load_template_from_json SHALL raise JSONDecodeError for invalid JSON. + """ + from samcli.lib.cfn_language_extensions import load_template_from_json + import json + + # Create an invalid JSON file + json_file = tmp_path / "invalid.json" + json_file.write_text("{ invalid json }") + + with pytest.raises(json.JSONDecodeError): + load_template_from_json(str(json_file)) + + def test_load_template_from_json_handles_complex_template(self, tmp_path): + """ + load_template_from_json SHALL handle complex templates with nested structures. + """ + from samcli.lib.cfn_language_extensions import load_template_from_json + import json + + template_content = { + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": {"Environment": {"Type": "String", "Default": "dev"}}, + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-12345"}}}, + "Resources": { + "MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": {"Fn::Sub": "queue-${Environment}"}}} + }, + } + json_file = tmp_path / "complex.json" + json_file.write_text(json.dumps(template_content)) + + result = load_template_from_json(str(json_file)) + + assert result["Parameters"]["Environment"]["Default"] == "dev" + assert result["Mappings"]["RegionMap"]["us-east-1"]["AMI"] == "ami-12345" + assert result["Resources"]["MyQueue"]["Properties"]["QueueName"] == {"Fn::Sub": "queue-${Environment}"} + + +class TestLoadTemplateFromYaml: + """Tests for the load_template_from_yaml function.""" + + def test_load_template_from_yaml_returns_dict(self, tmp_path): + """ + Requirement 12.4: load_template_from_yaml SHALL load a template from + a YAML file and return it as a dictionary. + """ + from samcli.lib.cfn_language_extensions import load_template_from_yaml + + # Create a test YAML file + yaml_content = """ +AWSTemplateFormatVersion: "2010-09-09" +Resources: + MyBucket: + Type: AWS::S3::Bucket +""" + yaml_file = tmp_path / "template.yaml" + yaml_file.write_text(yaml_content) + + result = load_template_from_yaml(str(yaml_file)) + + assert isinstance(result, dict) + assert result["AWSTemplateFormatVersion"] == "2010-09-09" + assert "MyBucket" in result["Resources"] + + def test_load_template_from_yaml_raises_for_missing_file(self, tmp_path): + """ + load_template_from_yaml SHALL raise FileNotFoundError for missing files. + """ + from samcli.lib.cfn_language_extensions import load_template_from_yaml + + with pytest.raises(FileNotFoundError): + load_template_from_yaml(str(tmp_path / "nonexistent.yaml")) + + def test_load_template_from_yaml_raises_for_invalid_yaml(self, tmp_path): + """ + load_template_from_yaml SHALL raise YAMLError for invalid YAML. + """ + from samcli.lib.cfn_language_extensions import load_template_from_yaml + import yaml + + # Create an invalid YAML file + yaml_file = tmp_path / "invalid.yaml" + yaml_file.write_text("key: [invalid: yaml") + + with pytest.raises(yaml.YAMLError): + load_template_from_yaml(str(yaml_file)) + + def test_load_template_from_yaml_handles_complex_template(self, tmp_path): + """ + load_template_from_yaml SHALL handle complex templates with nested structures. + """ + from samcli.lib.cfn_language_extensions import load_template_from_yaml + + yaml_content = """ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Environment: + Type: String + Default: dev +Mappings: + RegionMap: + us-east-1: + AMI: ami-12345 +Resources: + MyQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: + Fn::Sub: "queue-${Environment}" +""" + yaml_file = tmp_path / "complex.yaml" + yaml_file.write_text(yaml_content) + + result = load_template_from_yaml(str(yaml_file)) + + assert result["Parameters"]["Environment"]["Default"] == "dev" + assert result["Mappings"]["RegionMap"]["us-east-1"]["AMI"] == "ami-12345" + assert "MyQueue" in result["Resources"] + assert result["Resources"]["MyQueue"]["Properties"]["QueueName"] == {"Fn::Sub": "queue-${Environment}"} + + def test_load_template_from_yaml_handles_multiline_strings(self, tmp_path): + """ + load_template_from_yaml SHALL handle multi-line strings correctly. + """ + from samcli.lib.cfn_language_extensions import load_template_from_yaml + + yaml_content = """ +AWSTemplateFormatVersion: "2010-09-09" +Resources: + MyFunction: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, context): + return "Hello World" +""" + yaml_file = tmp_path / "multiline.yaml" + yaml_file.write_text(yaml_content) + + result = load_template_from_yaml(str(yaml_file)) + + code = result["Resources"]["MyFunction"]["Properties"]["Code"]["ZipFile"] + assert "def handler" in code + assert "Hello World" in code + + +class TestLoadTemplate: + """Tests for the load_template function with auto-detection.""" + + def test_load_template_detects_json_extension(self, tmp_path): + """ + load_template SHALL auto-detect JSON format from .json extension. + """ + from samcli.lib.cfn_language_extensions import load_template + import json + + template_content = {"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + json_file = tmp_path / "template.json" + json_file.write_text(json.dumps(template_content)) + + result = load_template(str(json_file)) + + assert "MyBucket" in result["Resources"] + + def test_load_template_detects_yaml_extension(self, tmp_path): + """ + load_template SHALL auto-detect YAML format from .yaml extension. + """ + from samcli.lib.cfn_language_extensions import load_template + + yaml_content = """ +Resources: + MyBucket: + Type: AWS::S3::Bucket +""" + yaml_file = tmp_path / "template.yaml" + yaml_file.write_text(yaml_content) + + result = load_template(str(yaml_file)) + + assert "MyBucket" in result["Resources"] + + def test_load_template_detects_yml_extension(self, tmp_path): + """ + load_template SHALL auto-detect YAML format from .yml extension. + """ + from samcli.lib.cfn_language_extensions import load_template + + yaml_content = """ +Resources: + MyBucket: + Type: AWS::S3::Bucket +""" + yml_file = tmp_path / "template.yml" + yml_file.write_text(yaml_content) + + result = load_template(str(yml_file)) + + assert "MyBucket" in result["Resources"] + + def test_load_template_detects_template_extension(self, tmp_path): + """ + load_template SHALL auto-detect YAML format from .template extension. + """ + from samcli.lib.cfn_language_extensions import load_template + + yaml_content = """ +Resources: + MyBucket: + Type: AWS::S3::Bucket +""" + template_file = tmp_path / "stack.template" + template_file.write_text(yaml_content) + + result = load_template(str(template_file)) + + assert "MyBucket" in result["Resources"] + + def test_load_template_raises_for_unknown_extension(self, tmp_path): + """ + load_template SHALL raise ValueError for unrecognized file extensions. + """ + from samcli.lib.cfn_language_extensions import load_template + + unknown_file = tmp_path / "template.txt" + unknown_file.write_text("some content") + + with pytest.raises(ValueError) as exc_info: + load_template(str(unknown_file)) + + assert "Unrecognized file extension" in str(exc_info.value) + assert ".txt" in str(exc_info.value) + + def test_load_template_case_insensitive_extension(self, tmp_path): + """ + load_template SHALL handle file extensions case-insensitively. + """ + from samcli.lib.cfn_language_extensions import load_template + import json + + template_content = {"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + json_file = tmp_path / "template.JSON" + json_file.write_text(json.dumps(template_content)) + + result = load_template(str(json_file)) + + assert "MyBucket" in result["Resources"] + + def test_load_template_raises_for_missing_file(self, tmp_path): + """ + load_template SHALL raise FileNotFoundError for missing files. + """ + from samcli.lib.cfn_language_extensions import load_template + + with pytest.raises(FileNotFoundError): + load_template(str(tmp_path / "nonexistent.json")) + + +# ============================================================================= +# Parametrized Tests for Partial Resolution Mode +# ============================================================================= + + +class TestPartialResolutionModeProperties: + """ + Parametrized tests for partial resolution mode. + + **Validates: Requirements 16.1, 16.2, 16.3, 16.4, 16.5** + + Property 17: Partial Resolution Preserves Unresolvable References + + For any template processed in partial resolution mode: + - Fn::GetAtt SHALL be preserved (16.1) + - Fn::ImportValue SHALL be preserved (16.2) + - Ref to resources SHALL be preserved (16.3) + - Resolvable intrinsics (Fn::Length, Fn::Join, etc.) SHALL still be resolved (16.4) + - Fn::GetAZs SHALL be preserved (16.5) + """ + + @pytest.mark.parametrize( + "resource_name, attribute_name", + [ + ("MyResource", "Arn"), + ("BucketA1", "DomainName"), + ("Queue123", "QueueUrl"), + ], + ) + def test_fn_get_att_preserved_in_partial_mode(self, resource_name: str, attribute_name: str): + """ + Property 17: Fn::GetAtt SHALL be preserved in partial resolution mode. + + **Validates: Requirements 16.1** + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::GetAtt": [resource_name, attribute_name]}}, + } + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == { + "Fn::GetAtt": [resource_name, attribute_name] + } + + @pytest.mark.parametrize( + "export_name", + [ + "SharedVpcId", + "cross-stack-output-123", + "DB_Endpoint", + ], + ) + def test_fn_import_value_preserved_in_partial_mode(self, export_name: str): + """ + Property 17: Fn::ImportValue SHALL be preserved in partial resolution mode. + + **Validates: Requirements 16.2** + """ + template = { + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": {"Fn::ImportValue": export_name}}} + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == {"Fn::ImportValue": export_name} + + @pytest.mark.parametrize( + "resource_name", + [ + "MyBucketResource", + "Queue1", + "LambdaFunction", + ], + ) + def test_ref_to_resource_preserved_in_partial_mode(self, resource_name: str): + """ + Property 17: Ref to resources SHALL be preserved in partial resolution mode. + + **Validates: Requirements 16.3** + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Ref": resource_name}}, + } + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == {"Ref": resource_name} + + @pytest.mark.parametrize( + "region", + [ + "", + "us-east-1", + "eu-west-1", + ], + ) + def test_fn_get_azs_preserved_in_partial_mode(self, region: str): + """ + Property 17: Fn::GetAZs SHALL be preserved in partial resolution mode. + + **Validates: Requirements 16.5** + """ + template = { + "Resources": { + "MySubnet": { + "Type": "AWS::EC2::Subnet", + "Properties": {"AvailabilityZones": {"Fn::GetAZs": region}}, + } + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + az_value = result["Resources"]["MySubnet"]["Properties"]["AvailabilityZones"] + assert az_value == {"Fn::GetAZs": region} + + @pytest.mark.parametrize( + "items", + [ + [], + ["a", "b", "c"], + ["item1", "item2", "item3", "item4", "item5"], + ], + ) + def test_fn_length_resolved_in_partial_mode(self, items: list): + """ + Property 17: Fn::Length SHALL be resolved in partial resolution mode. + + **Validates: Requirements 16.4** + """ + template = { + "Resources": {"MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"DelaySeconds": {"Fn::Length": items}}}} + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == len(items) + + @pytest.mark.parametrize( + "delimiter, items", + [ + ("-", ["my", "bucket", "name"]), + (",", ["a", "b", "c"]), + ("", ["x", "y"]), + ], + ) + def test_fn_join_resolved_in_partial_mode(self, delimiter: str, items: list): + """ + Property 17: Fn::Join SHALL be resolved in partial resolution mode. + + **Validates: Requirements 16.4** + """ + template = { + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": {"Fn::Join": [delimiter, items]}}} + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + expected = delimiter.join(items) + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == expected + + @pytest.mark.parametrize( + "data", + [ + {"key1": "value1"}, + {"name": "test", "count": "3"}, + {}, + ], + ) + def test_fn_to_json_string_resolved_in_partial_mode(self, data: dict): + """ + Property 17: Fn::ToJsonString SHALL be resolved in partial resolution mode. + + **Validates: Requirements 16.4** + """ + import json + + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": {"Environment": {"Fn::ToJsonString": data}}, + } + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + json_str = result["Resources"]["MyFunction"]["Properties"]["Environment"] + assert json.loads(json_str) == data + + @pytest.mark.parametrize( + "param_name, param_value", + [ + ("Environment", "production"), + ("BucketPrefix", "my-app"), + ("Region1", "us-west-2"), + ], + ) + def test_ref_to_parameter_resolved_in_partial_mode(self, param_name: str, param_value: str): + """ + Property 17: Ref to parameters SHALL be resolved in partial resolution mode. + + **Validates: Requirements 16.4** + """ + template = { + "Parameters": {param_name: {"Type": "String", "Default": "default-value"}}, + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": {"Ref": param_name}}}}, + } + + result = process_template( + template, parameter_values={param_name: param_value}, resolution_mode=ResolutionMode.PARTIAL + ) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == param_value + + @pytest.mark.parametrize( + "resource_name, attribute_name, items", + [ + ("MyResource", "Arn", ["a", "b", "c"]), + ("Queue1", "Id", ["item1"]), + ("Func", "Name", ["x", "y", "z", "w"]), + ], + ) + def test_mixed_resolvable_and_unresolvable_intrinsics(self, resource_name: str, attribute_name: str, items: list): + """ + Property 17: Mixed templates with both resolvable and unresolvable intrinsics + SHALL have resolvable ones resolved and unresolvable ones preserved. + + **Validates: Requirements 16.1, 16.2, 16.3, 16.4, 16.5** + """ + template = { + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySeconds": {"Fn::Length": items}, + "QueueName": {"Fn::GetAtt": [resource_name, attribute_name]}, + "Tags": [{"Key": "Items", "Value": {"Fn::Join": [",", items]}}], + }, + }, + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Ref": resource_name}}, + }, + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == len(items) + + assert result["Resources"]["MyQueue"]["Properties"]["QueueName"] == { + "Fn::GetAtt": [resource_name, attribute_name] + } + + assert result["Resources"]["MyQueue"]["Properties"]["Tags"][0]["Value"] == ",".join(items) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == {"Ref": resource_name} + + @pytest.mark.parametrize( + "resource_name, attribute_name, export_name", + [ + ("MyResource", "Arn", "SharedOutput"), + ("BucketRes", "Id", "cross-stack-val"), + ("Queue1", "Name", "DB_Endpoint"), + ], + ) + def test_nested_unresolvable_intrinsics_preserved(self, resource_name: str, attribute_name: str, export_name: str): + """ + Property 17: Nested unresolvable intrinsics SHALL be preserved. + + **Validates: Requirements 16.1, 16.2, 16.3, 16.4, 16.5** + """ + template = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": ["-", ["prefix", {"Fn::GetAtt": [resource_name, attribute_name]}, "suffix"]] + } + }, + } + } + } + + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + bucket_name = result["Resources"]["MyBucket"]["Properties"]["BucketName"] + + assert "Fn::GetAtt" in str(bucket_name) or isinstance(bucket_name, dict) + + +class TestLoadTemplateIntegration: + """Integration tests for loading and processing templates.""" + + def test_load_and_process_json_template(self, tmp_path): + """ + Templates loaded from JSON files SHALL be processable. + """ + from samcli.lib.cfn_language_extensions import load_template, process_template + import json + + template_content = { + "Resources": {"Fn::ForEach::Topics": ["Name", ["A", "B"], {"Topic${Name}": {"Type": "AWS::SNS::Topic"}}]} + } + json_file = tmp_path / "template.json" + json_file.write_text(json.dumps(template_content)) + + template = load_template(str(json_file)) + result = process_template(template) + + assert "TopicA" in result["Resources"] + assert "TopicB" in result["Resources"] + + def test_load_and_process_yaml_template(self, tmp_path): + """ + Templates loaded from YAML files SHALL be processable. + """ + from samcli.lib.cfn_language_extensions import load_template, process_template + + yaml_content = """ +Resources: + Fn::ForEach::Topics: + - Name + - [A, B] + - Topic${Name}: + Type: AWS::SNS::Topic +""" + yaml_file = tmp_path / "template.yaml" + yaml_file.write_text(yaml_content) + + template = load_template(str(yaml_file)) + result = process_template(template) + + assert "TopicA" in result["Resources"] + assert "TopicB" in result["Resources"] + + +class TestProcessTemplateAdditionalEdgeCases: + """Tests for process_template function additional edge cases.""" + + def test_process_template_with_empty_resources(self): + """Test processing template with empty Resources section.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Empty template", + "Resources": {}, + } + result = process_template(template) + assert "Description" in result + assert result["Resources"] == {} + + def test_process_template_preserves_unknown_sections(self): + """Test that unknown template sections are preserved.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Metadata": {"CustomKey": "CustomValue"}, + "Resources": {}, + } + result = process_template(template) + assert result["Metadata"]["CustomKey"] == "CustomValue" + + def test_process_template_with_condition_and_parameter(self): + """Test processing template with conditions referencing parameters.""" + template = { + "Parameters": {"Env": {"Type": "String", "Default": "prod"}}, + "Conditions": {"IsProduction": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}}, + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Condition": "IsProduction"}}, + } + result = process_template(template, parameter_values={"Env": "prod"}) + assert "IsProduction" in result.get("Conditions", {}) diff --git a/tests/unit/lib/cfn_language_extensions/test_api_intrinsic_resolver.py b/tests/unit/lib/cfn_language_extensions/test_api_intrinsic_resolver.py new file mode 100644 index 00000000000..00d21e375c8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_api_intrinsic_resolver.py @@ -0,0 +1,438 @@ +""" +Tests for IntrinsicResolverProcessor in api.py. + +Covers _partial_resolve, _resolve_with_false_condition, _resolve_resources_section, +_resolve_outputs_section, and _remove_no_value. +""" + +import pytest + +from samcli.lib.cfn_language_extensions.api import ( + IntrinsicResolverProcessor, + create_default_intrinsic_resolver, + create_default_pipeline, + process_template, +) +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ParsedTemplate, +) + + +class TestPartialResolve: + """Tests for _partial_resolve method.""" + + def _make_processor(self, context): + resolver = create_default_intrinsic_resolver(context) + return IntrinsicResolverProcessor(resolver) + + def test_primitive_value_returned_as_is(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + assert proc._partial_resolve("hello") == "hello" + assert proc._partial_resolve(42) == 42 + assert proc._partial_resolve(True) is True + + def test_list_recursively_resolved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + result = proc._partial_resolve(["a", "b", 3]) + assert result == ["a", "b", 3] + + def test_regular_dict_recursively_resolved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + result = proc._partial_resolve({"key1": "val1", "key2": 42}) + assert result == {"key1": "val1", "key2": 42} + + def test_ref_to_parameter_replaced_with_no_value(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"MyParam": "value"}, + ) + proc = self._make_processor(context) + result = proc._partial_resolve({"Ref": "MyParam"}) + assert result == {"Ref": "AWS::NoValue"} + + def test_ref_to_pseudo_param_preserved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"AWS::Region": "us-east-1"}, + ) + proc = self._make_processor(context) + result = proc._partial_resolve({"Ref": "AWS::Region"}) + assert result == "us-east-1" + + def test_ref_to_no_value_preserved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + result = proc._partial_resolve({"Ref": "AWS::NoValue"}) + assert result == {"Ref": "AWS::NoValue"} + + +class TestResolveWithFalseCondition: + """Tests for _resolve_with_false_condition.""" + + def test_delegates_to_partial_resolve(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + result = proc._resolve_with_false_condition("simple") + assert result == "simple" + + +class TestRemoveNoValue: + """Tests for _remove_no_value.""" + + def _make_processor(self, context): + resolver = create_default_intrinsic_resolver(context) + return IntrinsicResolverProcessor(resolver) + + def test_removes_no_value_from_dict(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + value = {"key1": "val1", "key2": {"Ref": "AWS::NoValue"}} + result = proc._remove_no_value(value) + assert "key1" in result + assert "key2" not in result + + def test_removes_no_value_from_list(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + value = ["a", {"Ref": "AWS::NoValue"}, "b"] + result = proc._remove_no_value(value) + assert result == ["a", "b"] + + def test_primitive_returned_as_is(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + proc = self._make_processor(context) + assert proc._remove_no_value("hello") == "hello" + assert proc._remove_no_value(42) == 42 + + +class TestProcessTemplateConditions: + """Tests for process_template with conditions.""" + + def test_false_condition_resource_uses_partial_resolution(self): + result = process_template( + { + "Conditions": { + "IsEnabled": {"Fn::Equals": ["false", "true"]}, + }, + "Resources": { + "MyFunc": { + "Type": "AWS::Serverless::Function", + "Condition": "IsEnabled", + "Properties": { + "CodeUri": "./src", + }, + } + }, + }, + parameter_values={}, + ) + # Resource should still exist but with partial resolution + assert "MyFunc" in result.get("Resources", {}) + + def test_true_condition_resource_fully_resolved(self): + result = process_template( + { + "Conditions": { + "IsEnabled": {"Fn::Equals": ["true", "true"]}, + }, + "Resources": { + "MyFunc": { + "Type": "AWS::Serverless::Function", + "Condition": "IsEnabled", + "Properties": { + "CodeUri": "./src", + }, + } + }, + }, + parameter_values={}, + ) + assert "MyFunc" in result.get("Resources", {}) + + def test_output_with_false_condition(self): + result = process_template( + { + "Conditions": { + "IsEnabled": {"Fn::Equals": ["false", "true"]}, + }, + "Resources": { + "MyFunc": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + "Outputs": { + "MyOutput": { + "Condition": "IsEnabled", + "Value": {"Ref": "MyFunc"}, + } + }, + }, + parameter_values={}, + ) + assert "Outputs" in result + + +class TestResolveResourcesSection: + """Tests for _resolve_resources_section edge cases.""" + + def test_non_dict_resources_resolved(self): + context = TemplateProcessingContext( + fragment={"Resources": "not a dict"}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + result = proc._resolve_resources_section("not a dict", context) + assert result == "not a dict" + + def test_non_dict_outputs_resolved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}, "Outputs": "not a dict"}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + result = proc._resolve_outputs_section("not a dict", context) + assert result == "not a dict" + + +class TestExtractConditionDependencies: + """Tests for _extract_condition_dependencies.""" + + def test_fn_if_recurses_into_values(self): + """Fn::If condition name is a plain string in the list, not a Condition ref, + so _extract_condition_dependencies won't add it — it only extracts from + {"Condition": "name"} dicts. But it does recurse into nested structures.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + # Fn::If with a nested Condition ref inside the true branch + deps = proc._extract_condition_dependencies( + {"Fn::If": ["MyCondition", {"Condition": "NestedCond"}, "false-val"]} + ) + assert "NestedCond" in deps + + def test_extracts_condition_from_condition_key(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + deps = proc._extract_condition_dependencies({"Condition": "MyCondition"}) + assert "MyCondition" in deps + + def test_no_conditions_in_primitive(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + deps = proc._extract_condition_dependencies("simple string") + assert len(deps) == 0 + + def test_recursive_extraction_from_list(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + deps = proc._extract_condition_dependencies([{"Condition": "Cond1"}, {"Condition": "Cond2"}]) + assert "Cond1" in deps + assert "Cond2" in deps + + +class TestResolveConditionsSection: + """Tests for _resolve_conditions_section.""" + + def test_non_dict_conditions(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + result = proc._resolve_conditions_section("not a dict") + assert result == "not a dict" + + def test_dict_conditions_resolved(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + result = proc._resolve_conditions_section({"IsEnabled": {"Fn::Equals": ["true", "true"]}}) + assert isinstance(result, dict) + assert "IsEnabled" in result + + +class TestIsNoValue: + """Tests for _is_no_value.""" + + def test_ref_no_value(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + assert proc._is_no_value({"Ref": "AWS::NoValue"}) is True + + def test_not_no_value(self): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + assert proc._is_no_value({"Ref": "MyParam"}) is False + assert proc._is_no_value("string") is False + assert proc._is_no_value(42) is False + + +class TestIntrinsicResolverProcessorPartialResolution: + """Tests for IntrinsicResolverProcessor partial resolution for false conditions.""" + + def test_partial_resolve_ref_to_parameter(self): + """Test partial resolution replaces Ref to parameters with AWS::NoValue.""" + template = { + "Parameters": {"MyParam": {"Type": "String", "Default": "value"}}, + "Conditions": {"AlwaysFalse": {"Fn::Equals": ["a", "b"]}}, + "Resources": { + "MyResource": { + "Type": "AWS::SNS::Topic", + "Condition": "AlwaysFalse", + "Properties": {"TopicName": {"Ref": "MyParam"}}, + } + }, + } + result = process_template(template) + assert "MyResource" in result["Resources"] + + def test_partial_resolve_fn_to_json_string(self): + """Test partial resolution replaces Fn::ToJsonString with AWS::NoValue.""" + template = { + "Conditions": {"AlwaysFalse": {"Fn::Equals": ["a", "b"]}}, + "Resources": { + "MyResource": { + "Type": "AWS::SNS::Topic", + "Condition": "AlwaysFalse", + "Properties": {"DisplayName": {"Fn::ToJsonString": {"key": "val"}}}, + } + }, + } + result = process_template(template) + assert "MyResource" in result["Resources"] + + +class TestIntrinsicResolverProcessorConditionValidation: + """Tests for IntrinsicResolverProcessor condition validation.""" + + def test_resource_references_non_existent_condition(self): + """Test that resource referencing non-existent condition raises error.""" + template = {"Resources": {"MyResource": {"Type": "AWS::SNS::Topic", "Condition": "NonExistentCondition"}}} + with pytest.raises(Exception): + process_template(template) + + +class TestIntrinsicResolverProcessorCircularConditions: + """Tests for IntrinsicResolverProcessor circular condition detection.""" + + def test_circular_condition_dependency_detected(self): + """Test that circular condition dependencies are detected.""" + template = { + "Conditions": {"CondA": {"Condition": "CondB"}, "CondB": {"Condition": "CondA"}}, + "Resources": {}, + } + with pytest.raises(Exception): + process_template(template) + + def test_self_referencing_condition_detected(self): + """Test that self-referencing condition is detected.""" + template = { + "Conditions": {"SelfRef": {"Condition": "SelfRef"}}, + "Resources": {}, + } + with pytest.raises(Exception): + process_template(template) + + +class TestExtractConditionDependenciesMultiKeyDict: + """Tests for _extract_condition_dependencies with multi-key dicts.""" + + def test_multi_key_dict_extracts_dependencies(self): + """Test that multi-key dicts have their values recursively searched.""" + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Conditions": { + "CondA": {"Fn::Equals": ["a", "a"]}, + "CondB": {"Fn::Equals": ["b", "b"]}, + }, + }, + parameter_values={}, + ) + resolver = create_default_intrinsic_resolver(context) + proc = IntrinsicResolverProcessor(resolver) + # A dict with multiple keys, each containing condition references + value = { + "key1": {"Condition": "CondA"}, + "key2": {"Condition": "CondB"}, + } + deps = proc._extract_condition_dependencies(value) + assert "CondA" in deps + assert "CondB" in deps + + +class TestResolveConditionsSectionMultiKeyDict: + """Tests for _resolve_conditions_section with multi-key condition values.""" + + def test_condition_with_multi_key_dict_value(self): + """Test resolving conditions where condition value has multiple keys.""" + template = { + "Parameters": {"Env": {"Type": "String", "Default": "prod"}}, + "Conditions": { + "IsProduction": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}, + }, + "Resources": { + "MyTopic": {"Type": "AWS::SNS::Topic", "Condition": "IsProduction"}, + }, + } + result = process_template(template, parameter_values={"Env": "prod"}) + assert "IsProduction" in result.get("Conditions", {}) diff --git a/tests/unit/lib/cfn_language_extensions/test_condition_resolver.py b/tests/unit/lib/cfn_language_extensions/test_condition_resolver.py new file mode 100644 index 00000000000..3f3d401f933 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_condition_resolver.py @@ -0,0 +1,883 @@ +""" +Unit tests for the ConditionResolver class. + +Tests cover: +- Fn::Equals functionality +- Fn::And functionality +- Fn::Or functionality +- Fn::Not functionality +- Condition reference functionality +- Circular condition reference detection +- Error handling for invalid inputs +- Integration with IntrinsicResolver orchestrator + +Requirements: + - 8.1: WHEN a Condition section contains language extension functions, THEN THE + Resolver SHALL resolve them before evaluating conditions + - 8.4: WHEN conditions contain circular references, THEN THE Resolver SHALL + raise an Invalid_Template_Exception +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.condition_resolver import ConditionResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestConditionResolverCanResolve: + """Tests for ConditionResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + """Create a ConditionResolver for testing.""" + return ConditionResolver(context, None) + + def test_can_resolve_fn_equals(self, resolver: ConditionResolver): + """Test that can_resolve returns True for Fn::Equals.""" + value = {"Fn::Equals": ["a", "b"]} + assert resolver.can_resolve(value) is True + + def test_can_resolve_fn_and(self, resolver: ConditionResolver): + """Test that can_resolve returns True for Fn::And.""" + value = {"Fn::And": [True, True]} + assert resolver.can_resolve(value) is True + + def test_can_resolve_fn_or(self, resolver: ConditionResolver): + """Test that can_resolve returns True for Fn::Or.""" + value = {"Fn::Or": [True, False]} + assert resolver.can_resolve(value) is True + + def test_can_resolve_fn_not(self, resolver: ConditionResolver): + """Test that can_resolve returns True for Fn::Not.""" + value = {"Fn::Not": [True]} + assert resolver.can_resolve(value) is True + + def test_can_resolve_condition(self, resolver: ConditionResolver): + """Test that can_resolve returns True for Condition.""" + value = {"Condition": "MyCondition"} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: ConditionResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + + def test_cannot_resolve_non_dict(self, resolver: ConditionResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: ConditionResolver): + """Test that FUNCTION_NAMES contains all condition functions.""" + expected = ["Fn::Equals", "Fn::And", "Fn::Or", "Fn::Not", "Condition"] + assert ConditionResolver.FUNCTION_NAMES == expected + + +class TestFnEqualsResolver: + """Tests for Fn::Equals functionality. + + Fn::Equals compares two values and returns true if they are equal. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + """Create a ConditionResolver for testing.""" + return ConditionResolver(context, None) + + def test_equals_same_strings(self, resolver: ConditionResolver): + """Test Fn::Equals with identical strings returns True.""" + value = {"Fn::Equals": ["value", "value"]} + assert resolver.resolve(value) is True + + def test_equals_different_strings(self, resolver: ConditionResolver): + """Test Fn::Equals with different strings returns False.""" + value = {"Fn::Equals": ["value1", "value2"]} + assert resolver.resolve(value) is False + + def test_equals_same_integers(self, resolver: ConditionResolver): + """Test Fn::Equals with identical integers returns True.""" + value = {"Fn::Equals": [42, 42]} + assert resolver.resolve(value) is True + + def test_equals_different_integers(self, resolver: ConditionResolver): + """Test Fn::Equals with different integers returns False.""" + value = {"Fn::Equals": [42, 43]} + assert resolver.resolve(value) is False + + def test_equals_same_booleans(self, resolver: ConditionResolver): + """Test Fn::Equals with identical booleans returns True.""" + value = {"Fn::Equals": [True, True]} + assert resolver.resolve(value) is True + + def test_equals_different_booleans(self, resolver: ConditionResolver): + """Test Fn::Equals with different booleans returns False.""" + value = {"Fn::Equals": [True, False]} + assert resolver.resolve(value) is False + + def test_equals_empty_strings(self, resolver: ConditionResolver): + """Test Fn::Equals with empty strings returns True.""" + value = {"Fn::Equals": ["", ""]} + assert resolver.resolve(value) is True + + def test_equals_case_sensitive(self, resolver: ConditionResolver): + """Test Fn::Equals is case-sensitive for strings.""" + value = {"Fn::Equals": ["Value", "value"]} + assert resolver.resolve(value) is False + + def test_equals_type_mismatch(self, resolver: ConditionResolver): + """Test Fn::Equals with different types returns False.""" + value = {"Fn::Equals": ["42", 42]} + assert resolver.resolve(value) is False + + def test_equals_invalid_layout_not_list(self, resolver: ConditionResolver): + """Test Fn::Equals with non-list raises InvalidTemplateException.""" + value = {"Fn::Equals": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Equals layout is incorrect" in str(exc_info.value) + + def test_equals_invalid_layout_one_element(self, resolver: ConditionResolver): + """Test Fn::Equals with one element raises InvalidTemplateException.""" + value = {"Fn::Equals": ["only-one"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Equals layout is incorrect" in str(exc_info.value) + + def test_equals_invalid_layout_three_elements(self, resolver: ConditionResolver): + """Test Fn::Equals with three elements raises InvalidTemplateException.""" + value = {"Fn::Equals": ["one", "two", "three"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Equals layout is incorrect" in str(exc_info.value) + + +class TestFnAndResolver: + """Tests for Fn::And functionality. + + Fn::And returns true if all conditions are true. It accepts 2-10 conditions. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + """Create a ConditionResolver for testing.""" + return ConditionResolver(context, None) + + def test_and_all_true(self, resolver: ConditionResolver): + """Test Fn::And with all true conditions returns True.""" + value = {"Fn::And": [True, True]} + assert resolver.resolve(value) is True + + def test_and_one_false(self, resolver: ConditionResolver): + """Test Fn::And with one false condition returns False.""" + value = {"Fn::And": [True, False]} + assert resolver.resolve(value) is False + + def test_and_all_false(self, resolver: ConditionResolver): + """Test Fn::And with all false conditions returns False.""" + value = {"Fn::And": [False, False]} + assert resolver.resolve(value) is False + + def test_and_multiple_conditions_all_true(self, resolver: ConditionResolver): + """Test Fn::And with multiple true conditions returns True.""" + value = {"Fn::And": [True, True, True, True, True]} + assert resolver.resolve(value) is True + + def test_and_multiple_conditions_one_false(self, resolver: ConditionResolver): + """Test Fn::And with multiple conditions, one false returns False.""" + value = {"Fn::And": [True, True, False, True, True]} + assert resolver.resolve(value) is False + + def test_and_ten_conditions(self, resolver: ConditionResolver): + """Test Fn::And with maximum 10 conditions.""" + value = {"Fn::And": [True] * 10} + assert resolver.resolve(value) is True + + def test_and_string_true(self, resolver: ConditionResolver): + """Test Fn::And with string 'true' values.""" + value = {"Fn::And": ["true", "true"]} + assert resolver.resolve(value) is True + + def test_and_string_false(self, resolver: ConditionResolver): + """Test Fn::And with string 'false' values.""" + value = {"Fn::And": ["true", "false"]} + assert resolver.resolve(value) is False + + def test_and_invalid_layout_not_list(self, resolver: ConditionResolver): + """Test Fn::And with non-list raises InvalidTemplateException.""" + value = {"Fn::And": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::And layout is incorrect" in str(exc_info.value) + + def test_and_invalid_layout_one_element(self, resolver: ConditionResolver): + """Test Fn::And with one element raises InvalidTemplateException.""" + value = {"Fn::And": [True]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::And layout is incorrect" in str(exc_info.value) + + def test_and_invalid_layout_eleven_elements(self, resolver: ConditionResolver): + """Test Fn::And with more than 10 elements raises InvalidTemplateException.""" + value = {"Fn::And": [True] * 11} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::And layout is incorrect" in str(exc_info.value) + + +class TestFnOrResolver: + """Tests for Fn::Or functionality. + + Fn::Or returns true if any condition is true. It accepts 2-10 conditions. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + """Create a ConditionResolver for testing.""" + return ConditionResolver(context, None) + + def test_or_all_true(self, resolver: ConditionResolver): + """Test Fn::Or with all true conditions returns True.""" + value = {"Fn::Or": [True, True]} + assert resolver.resolve(value) is True + + def test_or_one_true(self, resolver: ConditionResolver): + """Test Fn::Or with one true condition returns True.""" + value = {"Fn::Or": [False, True]} + assert resolver.resolve(value) is True + + def test_or_all_false(self, resolver: ConditionResolver): + """Test Fn::Or with all false conditions returns False.""" + value = {"Fn::Or": [False, False]} + assert resolver.resolve(value) is False + + def test_or_multiple_conditions_one_true(self, resolver: ConditionResolver): + """Test Fn::Or with multiple conditions, one true returns True.""" + value = {"Fn::Or": [False, False, True, False, False]} + assert resolver.resolve(value) is True + + def test_or_multiple_conditions_all_false(self, resolver: ConditionResolver): + """Test Fn::Or with multiple false conditions returns False.""" + value = {"Fn::Or": [False, False, False, False, False]} + assert resolver.resolve(value) is False + + def test_or_ten_conditions(self, resolver: ConditionResolver): + """Test Fn::Or with maximum 10 conditions.""" + conditions = [False] * 9 + [True] + value = {"Fn::Or": conditions} + assert resolver.resolve(value) is True + + def test_or_string_true(self, resolver: ConditionResolver): + """Test Fn::Or with string 'true' values.""" + value = {"Fn::Or": ["false", "true"]} + assert resolver.resolve(value) is True + + def test_or_string_false(self, resolver: ConditionResolver): + """Test Fn::Or with string 'false' values.""" + value = {"Fn::Or": ["false", "false"]} + assert resolver.resolve(value) is False + + def test_or_invalid_layout_not_list(self, resolver: ConditionResolver): + """Test Fn::Or with non-list raises InvalidTemplateException.""" + value = {"Fn::Or": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Or layout is incorrect" in str(exc_info.value) + + def test_or_invalid_layout_one_element(self, resolver: ConditionResolver): + """Test Fn::Or with one element raises InvalidTemplateException.""" + value = {"Fn::Or": [True]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Or layout is incorrect" in str(exc_info.value) + + def test_or_invalid_layout_eleven_elements(self, resolver: ConditionResolver): + """Test Fn::Or with more than 10 elements raises InvalidTemplateException.""" + value = {"Fn::Or": [False] * 11} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Or layout is incorrect" in str(exc_info.value) + + +class TestFnNotResolver: + """Tests for Fn::Not functionality. + + Fn::Not returns the inverse of a condition. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + """Create a ConditionResolver for testing.""" + return ConditionResolver(context, None) + + def test_not_true(self, resolver: ConditionResolver): + """Test Fn::Not with true returns False.""" + value = {"Fn::Not": [True]} + assert resolver.resolve(value) is False + + def test_not_false(self, resolver: ConditionResolver): + """Test Fn::Not with false returns True.""" + value = {"Fn::Not": [False]} + assert resolver.resolve(value) is True + + def test_not_string_true(self, resolver: ConditionResolver): + """Test Fn::Not with string 'true' returns False.""" + value = {"Fn::Not": ["true"]} + assert resolver.resolve(value) is False + + def test_not_string_false(self, resolver: ConditionResolver): + """Test Fn::Not with string 'false' returns True.""" + value = {"Fn::Not": ["false"]} + assert resolver.resolve(value) is True + + def test_not_invalid_layout_not_list(self, resolver: ConditionResolver): + """Test Fn::Not with non-list raises InvalidTemplateException.""" + value = {"Fn::Not": True} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Not layout is incorrect" in str(exc_info.value) + + def test_not_invalid_layout_empty_list(self, resolver: ConditionResolver): + """Test Fn::Not with empty list raises InvalidTemplateException.""" + value: Dict[str, Any] = {"Fn::Not": []} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Not layout is incorrect" in str(exc_info.value) + + def test_not_invalid_layout_two_elements(self, resolver: ConditionResolver): + """Test Fn::Not with two elements raises InvalidTemplateException.""" + value = {"Fn::Not": [True, False]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Not layout is incorrect" in str(exc_info.value) + + +class TestConditionReference: + """Tests for Condition reference functionality. + + The Condition intrinsic function references a named condition from + the Conditions section of the template. + """ + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + """Create a context with conditions defined.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "IsProduction": {"Fn::Equals": ["prod", "prod"]}, + "IsDevelopment": {"Fn::Equals": ["dev", "prod"]}, + "AlwaysTrue": True, + "AlwaysFalse": False, + }, + ) + return context + + @pytest.fixture + def orchestrator(self, context_with_conditions: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + return orchestrator + + def test_condition_reference_true(self, orchestrator: IntrinsicResolver): + """Test Condition reference to a true condition.""" + value = {"Condition": "AlwaysTrue"} + result = orchestrator.resolve_value(value) + assert result is True + + def test_condition_reference_false(self, orchestrator: IntrinsicResolver): + """Test Condition reference to a false condition.""" + value = {"Condition": "AlwaysFalse"} + result = orchestrator.resolve_value(value) + assert result is False + + def test_condition_reference_with_fn_equals(self, orchestrator: IntrinsicResolver): + """Test Condition reference to a condition with Fn::Equals.""" + value = {"Condition": "IsProduction"} + result = orchestrator.resolve_value(value) + assert result is True + + def test_condition_reference_with_fn_equals_false(self, orchestrator: IntrinsicResolver): + """Test Condition reference to a condition with Fn::Equals that is false.""" + value = {"Condition": "IsDevelopment"} + result = orchestrator.resolve_value(value) + assert result is False + + def test_condition_reference_caches_result(self, context_with_conditions: TemplateProcessingContext): + """Test that condition results are cached.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + + # First resolution + value = {"Condition": "IsProduction"} + orchestrator.resolve_value(value) + + # Check that result is cached + assert "IsProduction" in context_with_conditions.resolved_conditions + assert context_with_conditions.resolved_conditions["IsProduction"] is True + + def test_condition_reference_not_found(self, orchestrator: IntrinsicResolver): + """Test Condition reference to non-existent condition raises exception.""" + value = {"Condition": "NonExistentCondition"} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Condition 'NonExistentCondition' not found" in str(exc_info.value) + + def test_condition_reference_invalid_layout(self, orchestrator: IntrinsicResolver): + """Test Condition with non-string raises InvalidTemplateException.""" + value = {"Condition": 123} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Condition layout is incorrect" in str(exc_info.value) + + +class TestCircularConditionDetection: + """Tests for circular condition reference detection. + + Requirement 8.4: WHEN conditions contain circular references, THEN THE + Resolver SHALL raise an Invalid_Template_Exception + """ + + def test_direct_circular_reference(self): + """Test detection of direct circular reference (A -> A).""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "CircularCondition": {"Condition": "CircularCondition"}, + }, + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + + value = {"Condition": "CircularCondition"} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Circular condition reference detected" in str(exc_info.value) + + def test_indirect_circular_reference(self): + """Test detection of indirect circular reference (A -> B -> A).""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "ConditionA": {"Condition": "ConditionB"}, + "ConditionB": {"Condition": "ConditionA"}, + }, + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + + value = {"Condition": "ConditionA"} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Circular condition reference detected" in str(exc_info.value) + + def test_longer_circular_chain(self): + """Test detection of longer circular chain (A -> B -> C -> A).""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "ConditionA": {"Condition": "ConditionB"}, + "ConditionB": {"Condition": "ConditionC"}, + "ConditionC": {"Condition": "ConditionA"}, + }, + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + + value = {"Condition": "ConditionA"} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Circular condition reference detected" in str(exc_info.value) + + def test_circular_reference_in_fn_and(self): + """Test detection of circular reference within Fn::And.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "ConditionA": {"Fn::And": [True, {"Condition": "ConditionB"}]}, + "ConditionB": {"Condition": "ConditionA"}, + }, + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + + value = {"Condition": "ConditionA"} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Circular condition reference detected" in str(exc_info.value) + + def test_non_circular_chain(self): + """Test that non-circular chains work correctly.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "ConditionA": {"Condition": "ConditionB"}, + "ConditionB": {"Condition": "ConditionC"}, + "ConditionC": True, + }, + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + + value = {"Condition": "ConditionA"} + result = orchestrator.resolve_value(value) + + assert result is True + + +class TestNestedConditionFunctions: + """Tests for nested condition functions. + + Requirement 8.1: WHEN a Condition section contains language extension functions, + THEN THE Resolver SHALL resolve them before evaluating conditions + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + return orchestrator + + def test_nested_fn_equals_in_fn_and(self, orchestrator: IntrinsicResolver): + """Test Fn::And with nested Fn::Equals.""" + value = {"Fn::And": [{"Fn::Equals": ["a", "a"]}, {"Fn::Equals": ["b", "b"]}]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_nested_fn_equals_in_fn_or(self, orchestrator: IntrinsicResolver): + """Test Fn::Or with nested Fn::Equals.""" + value = {"Fn::Or": [{"Fn::Equals": ["a", "b"]}, {"Fn::Equals": ["c", "c"]}]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_nested_fn_not_in_fn_and(self, orchestrator: IntrinsicResolver): + """Test Fn::And with nested Fn::Not.""" + value = {"Fn::And": [{"Fn::Not": [False]}, {"Fn::Not": [False]}]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_deeply_nested_conditions(self, orchestrator: IntrinsicResolver): + """Test deeply nested condition functions.""" + value = { + "Fn::And": [ + {"Fn::Or": [{"Fn::Equals": ["a", "b"]}, {"Fn::Not": [False]}]}, + {"Fn::Not": [{"Fn::Equals": ["x", "y"]}]}, + ] + } + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_not_with_nested_fn_equals(self, orchestrator: IntrinsicResolver): + """Test Fn::Not with nested Fn::Equals.""" + value = {"Fn::Not": [{"Fn::Equals": ["a", "b"]}]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_not_with_nested_fn_and(self, orchestrator: IntrinsicResolver): + """Test Fn::Not with nested Fn::And.""" + value = {"Fn::Not": [{"Fn::And": [True, False]}]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_not_with_nested_fn_or(self, orchestrator: IntrinsicResolver): + """Test Fn::Not with nested Fn::Or.""" + value = {"Fn::Not": [{"Fn::Or": [False, False]}]} + result = orchestrator.resolve_value(value) + assert result is True + + +class TestConditionResolverWithOrchestrator: + """Tests for ConditionResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving condition functions through the orchestrator.""" + value = {"Fn::Equals": ["a", "a"]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving condition functions in a nested template structure.""" + value = { + "Conditions": { + "IsProduction": {"Fn::Equals": ["prod", "prod"]}, + "IsDevelopment": {"Fn::Equals": ["dev", "prod"]}, + } + } + result = orchestrator.resolve_value(value) + + assert result["Conditions"]["IsProduction"] is True + assert result["Conditions"]["IsDevelopment"] is False + + def test_resolve_multiple_conditions(self, orchestrator: IntrinsicResolver): + """Test resolving multiple condition functions in same structure.""" + value = { + "cond1": {"Fn::Equals": ["a", "a"]}, + "cond2": {"Fn::And": [True, True]}, + "cond3": {"Fn::Or": [False, True]}, + "cond4": {"Fn::Not": [False]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "cond1": True, + "cond2": True, + "cond3": True, + "cond4": True, + } + + def test_condition_in_list(self, orchestrator: IntrinsicResolver): + """Test condition functions inside a list.""" + value = [ + {"Fn::Equals": ["a", "a"]}, + {"Fn::Equals": ["a", "b"]}, + {"Fn::Not": [True]}, + ] + result = orchestrator.resolve_value(value) + + assert result == [True, False, False] + + +class TestConditionResolverPartialMode: + """Tests for ConditionResolver in partial resolution mode. + + Condition functions should always be resolved, even in partial mode. + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with ConditionResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(ConditionResolver) + return orchestrator + + def test_fn_equals_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Equals is resolved even in partial mode.""" + value = {"Fn::Equals": ["a", "a"]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_and_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::And is resolved even in partial mode.""" + value = {"Fn::And": [True, True]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_or_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Or is resolved even in partial mode.""" + value = {"Fn::Or": [False, True]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_fn_not_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Not is resolved even in partial mode.""" + value = {"Fn::Not": [False]} + result = orchestrator.resolve_value(value) + assert result is True + + def test_condition_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test condition functions alongside preserved intrinsics in partial mode.""" + value = { + "condition": {"Fn::Equals": ["a", "a"]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "condition": True, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +class TestConditionResolverAdditionalEdgeCases: + """Tests for ConditionResolver additional edge cases.""" + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + conditions = { + "IsProduction": {"Fn::Equals": ["prod", "prod"]}, + "IsDevelopment": {"Fn::Equals": ["dev", "prod"]}, + } + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Conditions": conditions}) + ctx.parsed_template = ParsedTemplate(resources={}, conditions=conditions) + return ctx + + def test_unknown_function_not_resolvable(self, context_with_conditions: TemplateProcessingContext): + """Test that unknown function name is not resolvable by ConditionResolver.""" + resolver = ConditionResolver(context_with_conditions, None) + value = {"Fn::Unknown": ["a", "b"]} + assert not resolver.can_resolve(value) + + def test_ref_in_condition_function_raises_exception(self, context_with_conditions: TemplateProcessingContext): + """Test that Ref inside Fn::And raises exception.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + value = {"Fn::And": [{"Ref": "SomeParam"}, True]} + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + assert "boolean operations" in str(exc_info.value).lower() + + def test_condition_not_found_raises_exception(self, context_with_conditions: TemplateProcessingContext): + """Test that referencing non-existent condition raises exception.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + value = {"Condition": "NonExistentCondition"} + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + assert "not found" in str(exc_info.value).lower() + + def test_to_boolean_with_non_boolean_value(self, context_with_conditions: TemplateProcessingContext): + """Test _to_boolean with non-boolean, non-string values.""" + resolver = ConditionResolver(context_with_conditions, None) + assert resolver._to_boolean(1) is True + assert resolver._to_boolean(0) is False + assert resolver._to_boolean([1, 2, 3]) is True + assert resolver._to_boolean([]) is False + + +class TestConditionResolverStringBooleans: + """Tests for ConditionResolver handling of string booleans.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + ctx = TemplateProcessingContext(fragment={"Resources": {}}) + ctx.parsed_template = ParsedTemplate(resources={}, conditions={}) + return ctx + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> ConditionResolver: + return ConditionResolver(context, None) + + def test_string_true_in_fn_not(self, resolver: ConditionResolver): + result = resolver._resolve_not(["true"]) + assert result is False + + def test_string_false_in_fn_not(self, resolver: ConditionResolver): + result = resolver._resolve_not(["false"]) + assert result is True + + def test_string_TRUE_uppercase_in_fn_not(self, resolver: ConditionResolver): + result = resolver._resolve_not(["TRUE"]) + assert result is False diff --git a/tests/unit/lib/cfn_language_extensions/test_deletion_policy.py b/tests/unit/lib/cfn_language_extensions/test_deletion_policy.py new file mode 100644 index 00000000000..d50da2f1bfd --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_deletion_policy.py @@ -0,0 +1,719 @@ +""" +Unit tests for the DeletionPolicyProcessor class. + +Tests cover: +- String policy values (valid) +- Parameter reference resolution +- AWS::NoValue rejection +- Invalid policy value handling +- Error messages + +Requirements: + - 7.1: WHEN a resource has a DeletionPolicy attribute, THEN THE Processor + SHALL resolve any parameter references in the policy value + - 7.3: WHEN DeletionPolicy contains a Ref to a parameter, THEN THE Processor + SHALL substitute the parameter's value + - 7.4: WHEN DeletionPolicy resolves to AWS::NoValue, THEN THE Processor + SHALL raise an Invalid_Template_Exception + - 7.5: WHEN DeletionPolicy does not resolve to a valid string value, THEN + THE Processor SHALL raise an Invalid_Template_Exception +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.processors.deletion_policy import DeletionPolicyProcessor +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestDeletionPolicyProcessorStringValues: + """Tests for DeletionPolicyProcessor with string policy values.""" + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_string_delete_policy_unchanged(self, processor: DeletionPolicyProcessor): + """Test that string 'Delete' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": "Delete"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Delete" + + def test_string_retain_policy_unchanged(self, processor: DeletionPolicyProcessor): + """Test that string 'Retain' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": "Retain"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Retain" + + def test_string_snapshot_policy_unchanged(self, processor: DeletionPolicyProcessor): + """Test that string 'Snapshot' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyVolume": {"Type": "AWS::EC2::Volume", "DeletionPolicy": "Snapshot"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyVolume"]["DeletionPolicy"] == "Snapshot" + + def test_resource_without_deletion_policy_unchanged(self, processor: DeletionPolicyProcessor): + """Test that resources without DeletionPolicy are unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "my-bucket"}}}} + ) + + processor.process_template(context) + + assert "DeletionPolicy" not in context.fragment["Resources"]["MyBucket"] + + def test_multiple_resources_with_policies(self, processor: DeletionPolicyProcessor): + """Test processing multiple resources with different policies.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "DeletionPolicy": "Retain"}, + "Bucket2": {"Type": "AWS::S3::Bucket", "DeletionPolicy": "Delete"}, + "Volume1": {"Type": "AWS::EC2::Volume", "DeletionPolicy": "Snapshot"}, + } + } + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["DeletionPolicy"] == "Retain" + assert context.fragment["Resources"]["Bucket2"]["DeletionPolicy"] == "Delete" + assert context.fragment["Resources"]["Volume1"]["DeletionPolicy"] == "Snapshot" + + +class TestDeletionPolicyProcessorParameterResolution: + """Tests for DeletionPolicyProcessor parameter reference resolution. + + Requirement 7.1: WHEN a resource has a DeletionPolicy attribute, THEN THE + Processor SHALL resolve any parameter references in the policy value + + Requirement 7.3: WHEN DeletionPolicy contains a Ref to a parameter, THEN + THE Processor SHALL substitute the parameter's value + """ + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_resolve_ref_to_parameter_retain(self, processor: DeletionPolicyProcessor): + """Test resolving Ref to parameter with 'Retain' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}}}, + parameter_values={"PolicyParam": "Retain"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Retain" + + def test_resolve_ref_to_parameter_delete(self, processor: DeletionPolicyProcessor): + """Test resolving Ref to parameter with 'Delete' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}}}, + parameter_values={"PolicyParam": "Delete"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Delete" + + def test_resolve_ref_to_parameter_snapshot(self, processor: DeletionPolicyProcessor): + """Test resolving Ref to parameter with 'Snapshot' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyVolume": {"Type": "AWS::EC2::Volume", "DeletionPolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": "Snapshot"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyVolume"]["DeletionPolicy"] == "Snapshot" + + def test_resolve_ref_to_parameter_default_value(self, processor: DeletionPolicyProcessor): + """Test resolving Ref to parameter using default value. + + Requirement 7.1: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}}}, + parameter_values={}, + parsed_template=ParsedTemplate( + parameters={"PolicyParam": {"Type": "String", "Default": "Retain"}}, resources={} + ), + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Retain" + + def test_resolve_multiple_refs_to_same_parameter(self, processor: DeletionPolicyProcessor): + """Test resolving multiple Refs to the same parameter. + + Requirement 7.1: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}, + "Bucket2": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}, + } + }, + parameter_values={"PolicyParam": "Retain"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["DeletionPolicy"] == "Retain" + assert context.fragment["Resources"]["Bucket2"]["DeletionPolicy"] == "Retain" + + def test_resolve_refs_to_different_parameters(self, processor: DeletionPolicyProcessor): + """Test resolving Refs to different parameters. + + Requirement 7.1: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "BucketPolicy"}}, + "Volume1": {"Type": "AWS::EC2::Volume", "DeletionPolicy": {"Ref": "VolumePolicy"}}, + } + }, + parameter_values={"BucketPolicy": "Retain", "VolumePolicy": "Snapshot"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["DeletionPolicy"] == "Retain" + assert context.fragment["Resources"]["Volume1"]["DeletionPolicy"] == "Snapshot" + + +class TestDeletionPolicyProcessorAwsNoValue: + """Tests for DeletionPolicyProcessor AWS::NoValue rejection. + + Requirement 7.4: WHEN DeletionPolicy resolves to AWS::NoValue, THEN THE + Processor SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_ref_to_aws_novalue_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that Ref to AWS::NoValue raises InvalidTemplateException. + + Requirement 7.4: Raise exception for AWS::NoValue references + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "AWS::NoValue"}}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "AWS::NoValue is not supported for DeletionPolicy or UpdateReplacePolicy" in str(exc_info.value) + + def test_aws_novalue_error_message_format(self, processor: DeletionPolicyProcessor): + """Test that AWS::NoValue error message matches expected format. + + Requirement 7.4: Raise exception for AWS::NoValue references + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "AWS::NoValue"}}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + # Verify exact error message format + error_message = str(exc_info.value) + assert "AWS::NoValue" in error_message + assert "DeletionPolicy" in error_message or "UpdateReplacePolicy" in error_message + + +class TestDeletionPolicyProcessorInvalidValues: + """Tests for DeletionPolicyProcessor invalid value handling. + + Requirement 7.5: WHEN DeletionPolicy does not resolve to a valid string + value, THEN THE Processor SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_list_policy_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that list policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": ["Retain", "Delete"]}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Every DeletionPolicy member must be a string" in str(exc_info.value) + + def test_integer_policy_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that integer policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": 123}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_boolean_policy_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that boolean policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": True}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_ref_to_nonexistent_parameter_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that Ref to non-existent parameter raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unresolvable references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "NonExistentParam"}}} + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + assert "MyBucket" in str(exc_info.value) + + def test_ref_to_resource_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that Ref to resource raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unresolvable references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "OtherResource"}}, + "OtherResource": {"Type": "AWS::SNS::Topic"}, + } + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_parameter_resolves_to_non_string_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that parameter resolving to non-string raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string resolved values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}}}, + parameter_values={"PolicyParam": 123}, # Integer, not string + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_parameter_resolves_to_list_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that parameter resolving to list raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string resolved values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}}}}, + parameter_values={"PolicyParam": ["Retain"]}, # List, not string + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_unsupported_intrinsic_function_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that unsupported intrinsic function raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Fn::Sub": "Retain"}}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_fn_if_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that Fn::If raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": {"Fn::If": ["IsProd", "Retain", "Delete"]}, + } + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + def test_fn_getatt_raises_exception(self, processor: DeletionPolicyProcessor): + """Test that Fn::GetAtt raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": {"Fn::GetAtt": ["SomeResource", "SomeAttribute"]}, + } + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for DeletionPolicy" in str(exc_info.value) + + +class TestDeletionPolicyProcessorEdgeCases: + """Tests for DeletionPolicyProcessor edge cases.""" + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_empty_resources_section(self, processor: DeletionPolicyProcessor): + """Test processing template with empty Resources section.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + + # Should not raise any exception + processor.process_template(context) + + assert context.fragment["Resources"] == {} + + def test_missing_resources_section(self, processor: DeletionPolicyProcessor): + """Test processing template without Resources section.""" + context = TemplateProcessingContext(fragment={}) + + # Should not raise any exception + processor.process_template(context) + + assert "Resources" not in context.fragment + + def test_non_dict_resources_section(self, processor: DeletionPolicyProcessor): + """Test processing template with non-dict Resources section.""" + context = TemplateProcessingContext(fragment={"Resources": "not a dict"}) + + # Should not raise any exception (handled gracefully) + processor.process_template(context) + + def test_non_dict_resource_definition(self, processor: DeletionPolicyProcessor): + """Test processing template with non-dict resource definition.""" + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": "not a dict"}}) + + # Should not raise any exception (handled gracefully) + processor.process_template(context) + + def test_null_deletion_policy(self, processor: DeletionPolicyProcessor): + """Test processing resource with null DeletionPolicy.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": None}}} + ) + + # Should not raise any exception (None is skipped) + processor.process_template(context) + + # DeletionPolicy should remain None + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] is None + + def test_policy_name_attribute(self, processor: DeletionPolicyProcessor): + """Test that POLICY_NAME attribute is correct.""" + assert processor.POLICY_NAME == "DeletionPolicy" + + def test_unsupported_pseudo_params_attribute(self, processor: DeletionPolicyProcessor): + """Test that UNSUPPORTED_PSEUDO_PARAMS contains AWS::NoValue.""" + assert "AWS::NoValue" in processor.UNSUPPORTED_PSEUDO_PARAMS + + +class TestDeletionPolicyProcessorErrorMessages: + """Tests for DeletionPolicyProcessor error message formatting.""" + + @pytest.fixture + def processor(self) -> DeletionPolicyProcessor: + """Create a DeletionPolicyProcessor for testing.""" + return DeletionPolicyProcessor() + + def test_error_message_includes_resource_logical_id(self, processor: DeletionPolicyProcessor): + """Test that error message includes the resource logical ID.""" + context = TemplateProcessingContext( + fragment={ + "Resources": {"MySpecialBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "NonExistent"}}} + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "MySpecialBucket" in str(exc_info.value) + + def test_error_message_includes_policy_name(self, processor: DeletionPolicyProcessor): + """Test that error message includes the policy name.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "NonExistent"}}}}, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "DeletionPolicy" in str(exc_info.value) + + +# ============================================================================= +# Parametrized Tests for DeletionPolicy Parameter Resolution +# ============================================================================= + + +class TestDeletionPolicyPropertyBasedTests: + """ + Parametrized tests for DeletionPolicy parameter resolution. + + Feature: cfn-language-extensions-python, Property 12: Policy Parameter Resolution + + These tests validate that for any valid policy value (Delete, Retain, Snapshot) + passed as a parameter, the DeletionPolicyProcessor correctly resolves the Ref + to that value. + + **Validates: Requirements 7.1, 7.2** + """ + + @pytest.mark.parametrize( + "policy_value,param_name,resource_id", + [ + ("Delete", "DeletionParam", "MyBucket"), + ("Retain", "RetainPolicy", "ProdDatabase"), + ("Snapshot", "SnapParam", "Volume01"), + ], + ids=["delete-policy", "retain-policy", "snapshot-policy"], + ) + def test_deletion_policy_resolves_parameter_ref_to_value( + self, + policy_value: str, + param_name: str, + resource_id: str, + ): + """ + Property 12: Policy Parameter Resolution + + For any valid policy value (Delete, Retain, Snapshot) passed as a parameter, + the DeletionPolicyProcessor correctly resolves the Ref to that value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = DeletionPolicyProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {resource_id: {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": param_name}}}}, + parameter_values={param_name: policy_value}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"][resource_id]["DeletionPolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_value,param_name", + [ + ("Delete", "DefaultDeleteParam"), + ("Retain", "DefaultRetainParam"), + ("Snapshot", "DefaultSnapParam"), + ], + ids=["default-delete", "default-retain", "default-snapshot"], + ) + def test_deletion_policy_resolves_parameter_default_value( + self, + policy_value: str, + param_name: str, + ): + """ + Property 12: Policy Parameter Resolution + + For any valid policy value (Delete, Retain, Snapshot) set as a parameter + default, the DeletionPolicyProcessor correctly resolves the Ref to that value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = DeletionPolicyProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"MyResource": {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": param_name}}}}, + parameter_values={}, + parsed_template=ParsedTemplate( + parameters={param_name: {"Type": "String", "Default": policy_value}}, resources={} + ), + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyResource"]["DeletionPolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_value,num_resources", + [ + ("Delete", 1), + ("Retain", 3), + ("Snapshot", 5), + ], + ids=["delete-1-resource", "retain-3-resources", "snapshot-5-resources"], + ) + def test_deletion_policy_resolves_same_parameter_across_multiple_resources( + self, + policy_value: str, + num_resources: int, + ): + """ + Property 12: Policy Parameter Resolution + + For any valid policy value passed as a parameter, the DeletionPolicyProcessor + correctly resolves the Ref to that value across multiple resources. + + **Validates: Requirements 7.1, 7.2** + """ + processor = DeletionPolicyProcessor() + resources = {} + for i in range(num_resources): + resources[f"Resource{i}"] = {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": "PolicyParam"}} + + context = TemplateProcessingContext( + fragment={"Resources": resources}, parameter_values={"PolicyParam": policy_value} + ) + + processor.process_template(context) + + for i in range(num_resources): + assert context.fragment["Resources"][f"Resource{i}"]["DeletionPolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_values", + [ + ["Delete"], + ["Retain", "Snapshot"], + ["Delete", "Retain", "Snapshot"], + ], + ids=["single-value", "two-values", "three-values"], + ) + def test_deletion_policy_resolves_different_parameters_to_different_values( + self, + policy_values: List[str], + ): + """ + Property 12: Policy Parameter Resolution + + For any set of valid policy values passed as different parameters, the + DeletionPolicyProcessor correctly resolves each Ref to its respective value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = DeletionPolicyProcessor() + resources = {} + parameter_values = {} + + for i, policy_value in enumerate(policy_values): + param_name = f"PolicyParam{i}" + resources[f"Resource{i}"] = {"Type": "AWS::S3::Bucket", "DeletionPolicy": {"Ref": param_name}} + parameter_values[param_name] = policy_value + + context = TemplateProcessingContext(fragment={"Resources": resources}, parameter_values=parameter_values) + + processor.process_template(context) + + for i, policy_value in enumerate(policy_values): + assert context.fragment["Resources"][f"Resource{i}"]["DeletionPolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_value", + ["Delete", "Retain", "Snapshot"], + ids=["string-delete", "string-retain", "string-snapshot"], + ) + def test_deletion_policy_string_value_unchanged( + self, + policy_value: str, + ): + """ + Property 12: Policy Parameter Resolution + + For any valid policy value already set as a string, the DeletionPolicyProcessor + leaves it unchanged. + + **Validates: Requirements 7.1, 7.2** + """ + processor = DeletionPolicyProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"MyResource": {"Type": "AWS::S3::Bucket", "DeletionPolicy": policy_value}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyResource"]["DeletionPolicy"] == policy_value diff --git a/tests/unit/lib/cfn_language_extensions/test_exceptions.py b/tests/unit/lib/cfn_language_extensions/test_exceptions.py new file mode 100644 index 00000000000..353a7339782 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_exceptions.py @@ -0,0 +1,204 @@ +""" +Unit tests for exception classes. + +Tests cover: +- InvalidTemplateException with and without cause chaining +- UnresolvableReferenceError for partial resolution +- PublicFacingErrorMessages matching Kotlin implementation +""" + +import pytest + +from samcli.lib.cfn_language_extensions import ( + InvalidTemplateException, + UnresolvableReferenceError, + PublicFacingErrorMessages, +) + + +class TestInvalidTemplateException: + """Tests for InvalidTemplateException class.""" + + def test_basic_exception_message(self): + """Test exception with just a message.""" + exc = InvalidTemplateException("Test error message") + + assert str(exc) == "Test error message" + assert exc.args[0] == "Test error message" + assert exc.cause is None + + def test_exception_with_cause(self): + """Test exception with cause chaining (Requirement 11.4).""" + original_error = KeyError("missing_key") + exc = InvalidTemplateException("Mapping lookup failed", cause=original_error) + + assert exc.cause is original_error + assert "Mapping lookup failed" in str(exc) + assert "caused by:" in str(exc) + assert "missing_key" in str(exc) + + def test_exception_str_without_cause(self): + """Test __str__ returns just message when no cause.""" + exc = InvalidTemplateException("Simple error") + + assert str(exc) == "Simple error" + + def test_exception_str_with_cause(self): + """Test __str__ includes cause information.""" + cause = ValueError("Invalid value") + exc = InvalidTemplateException("Processing failed", cause=cause) + + result = str(exc) + assert result == "Processing failed (caused by: Invalid value)" + + def test_exception_is_catchable_as_exception(self): + """Test that InvalidTemplateException can be caught as Exception.""" + with pytest.raises(Exception): + raise InvalidTemplateException("Test") + + def test_exception_preserves_cause_type(self): + """Test that the cause exception type is preserved.""" + cause = TypeError("type error") + exc = InvalidTemplateException("Wrapper", cause=cause) + + assert isinstance(exc.cause, TypeError) + + +class TestUnresolvableReferenceError: + """Tests for UnresolvableReferenceError class.""" + + def test_ref_to_resource(self): + """Test error for Ref to a resource.""" + exc = UnresolvableReferenceError("Ref", "MyBucket") + + assert exc.reference_type == "Ref" + assert exc.reference_target == "MyBucket" + assert str(exc) == "Cannot resolve Ref to 'MyBucket'" + + def test_getatt_reference(self): + """Test error for Fn::GetAtt reference.""" + exc = UnresolvableReferenceError("Fn::GetAtt", "MyBucket.Arn") + + assert exc.reference_type == "Fn::GetAtt" + assert exc.reference_target == "MyBucket.Arn" + assert str(exc) == "Cannot resolve Fn::GetAtt to 'MyBucket.Arn'" + + def test_import_value_reference(self): + """Test error for Fn::ImportValue reference.""" + exc = UnresolvableReferenceError("Fn::ImportValue", "SharedVpcId") + + assert exc.reference_type == "Fn::ImportValue" + assert exc.reference_target == "SharedVpcId" + assert str(exc) == "Cannot resolve Fn::ImportValue to 'SharedVpcId'" + + def test_exception_is_catchable(self): + """Test that UnresolvableReferenceError can be caught.""" + with pytest.raises(UnresolvableReferenceError) as exc_info: + raise UnresolvableReferenceError("Ref", "TestResource") + + assert exc_info.value.reference_type == "Ref" + assert exc_info.value.reference_target == "TestResource" + + +class TestPublicFacingErrorMessages: + """Tests for PublicFacingErrorMessages class matching Kotlin implementation.""" + + def test_internal_failure_constant(self): + """Test INTERNAL_FAILURE constant.""" + assert PublicFacingErrorMessages.INTERNAL_FAILURE == "Internal Failure" + + def test_invalid_input_constant(self): + """Test INVALID_INPUT constant.""" + assert PublicFacingErrorMessages.INVALID_INPUT == ( + "Invalid input passed to the AWS::LanguageExtensions Transform" + ) + + def test_unresolved_conditions_constant(self): + """Test UNRESOLVED_CONDITIONS constant.""" + assert PublicFacingErrorMessages.UNRESOLVED_CONDITIONS == ("Unable to resolve Conditions section") + + def test_error_parsing_template_constant(self): + """Test ERROR_PARSING_TEMPLATE constant (Requirement 11.5).""" + assert PublicFacingErrorMessages.ERROR_PARSING_TEMPLATE == ("Error parsing the template") + + def test_not_supported_for_policies(self): + """Test not_supported_for_policies message.""" + result = PublicFacingErrorMessages.not_supported_for_policies("AWS::NoValue") + + assert result == "AWS::NoValue is not supported for DeletionPolicy or UpdateReplacePolicy" + + def test_unresolved_policy(self): + """Test unresolved_policy message.""" + result = PublicFacingErrorMessages.unresolved_policy("DeletionPolicy", "MyResource") + + assert result == "Unsupported expression for DeletionPolicy in resource MyResource" + + def test_resolution_error(self): + """Test resolution_error message (Requirement 11.2).""" + result = PublicFacingErrorMessages.resolution_error("MyLambdaFunction") + + assert result == "Error resolving resource MyLambdaFunction in template" + + def test_resolve_type_mismatch(self): + """Test resolve_type_mismatch message (Requirement 11.3).""" + result = PublicFacingErrorMessages.resolve_type_mismatch("Fn::Length") + + assert result == "Fn::Length resolve value type mismatch" + + def test_invalid_policy_string(self): + """Test invalid_policy_string message.""" + result = PublicFacingErrorMessages.invalid_policy_string("DeletionPolicy") + + assert result == "Every DeletionPolicy member must be a string" + + def test_layout_incorrect(self): + """Test layout_incorrect message (Requirement 11.1).""" + result = PublicFacingErrorMessages.layout_incorrect("Fn::Length") + + assert result == "Fn::Length layout is incorrect" + + def test_layout_incorrect_various_functions(self): + """Test layout_incorrect for various function names.""" + functions = ["Fn::ToJsonString", "Fn::FindInMap", "Fn::ForEach", "Fn::Sub"] + + for fn_name in functions: + result = PublicFacingErrorMessages.layout_incorrect(fn_name) + assert result == f"{fn_name} layout is incorrect" + + +class TestExceptionIntegration: + """Integration tests for exception usage patterns.""" + + def test_invalid_template_with_layout_error_message(self): + """Test InvalidTemplateException with layout_incorrect message.""" + message = PublicFacingErrorMessages.layout_incorrect("Fn::Length") + exc = InvalidTemplateException(message) + + assert str(exc) == "Fn::Length layout is incorrect" + + def test_invalid_template_with_resolution_error_message(self): + """Test InvalidTemplateException with resolution_error message.""" + message = PublicFacingErrorMessages.resolution_error("MyResource") + exc = InvalidTemplateException(message) + + assert str(exc) == "Error resolving resource MyResource in template" + + def test_invalid_template_with_type_mismatch_message(self): + """Test InvalidTemplateException with resolve_type_mismatch message.""" + message = PublicFacingErrorMessages.resolve_type_mismatch("Fn::Sub") + exc = InvalidTemplateException(message) + + assert str(exc) == "Fn::Sub resolve value type mismatch" + + def test_chained_exception_pattern(self): + """Test typical exception chaining pattern.""" + try: + # Simulate a lookup failure + mappings = {"RegionMap": {"us-east-1": {"AMI": "ami-12345"}}} + _ = mappings["RegionMap"]["us-west-2"]["AMI"] + except KeyError as e: + exc = InvalidTemplateException("Mapping 'RegionMap' key lookup failed", cause=e) + + assert exc.cause is not None + assert "us-west-2" in str(exc.cause) + assert "caused by:" in str(exc) diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_base64.py b/tests/unit/lib/cfn_language_extensions/test_fn_base64.py new file mode 100644 index 00000000000..830478061e8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_base64.py @@ -0,0 +1,579 @@ +""" +Unit tests for the FnBase64Resolver class. + +Tests cover: +- Basic Fn::Base64 functionality with literal strings +- Nested intrinsic function resolution +- Error handling for non-string inputs +- Integration with IntrinsicResolver orchestrator +- Edge cases (empty strings, unicode, special characters) + +Requirements: + - 10.8: THE Resolver SHALL support Fn::Base64 for base64 encoding strings +""" + +import base64 +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_base64 import FnBase64Resolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnBase64ResolverCanResolve: + """Tests for FnBase64Resolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnBase64Resolver: + """Create a FnBase64Resolver for testing.""" + return FnBase64Resolver(context, None) + + def test_can_resolve_fn_base64(self, resolver: FnBase64Resolver): + """Test that can_resolve returns True for Fn::Base64.""" + value = {"Fn::Base64": "hello"} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnBase64Resolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnBase64Resolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_cannot_resolve_multi_key_dict(self, resolver: FnBase64Resolver): + """Test that can_resolve returns False for dicts with multiple keys.""" + assert resolver.can_resolve({"Fn::Base64": "hello", "extra": "key"}) is False + + def test_function_names_attribute(self, resolver: FnBase64Resolver): + """Test that FUNCTION_NAMES contains Fn::Base64.""" + assert FnBase64Resolver.FUNCTION_NAMES == ["Fn::Base64"] + + +class TestFnBase64ResolverBasicFunctionality: + """Tests for basic Fn::Base64 functionality. + + Requirement 10.8: THE Resolver SHALL support Fn::Base64 for base64 encoding strings + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnBase64Resolver: + """Create a FnBase64Resolver for testing.""" + return FnBase64Resolver(context, None) + + def test_base64_encode_simple_string(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with simple string. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": "hello"} + result = resolver.resolve(value) + + # "hello" in base64 is "aGVsbG8=" + assert result == "aGVsbG8=" + + def test_base64_encode_empty_string(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with empty string returns empty string. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": ""} + result = resolver.resolve(value) + + assert result == "" + + def test_base64_encode_with_spaces(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with string containing spaces. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": "hello world"} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == "hello world" + + def test_base64_encode_with_newlines(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with string containing newlines. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": "line1\nline2\nline3"} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == "line1\nline2\nline3" + + def test_base64_encode_with_special_characters(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with special characters. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": "!@#$%^&*()_+-=[]{}|;':\",./<>?"} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == "!@#$%^&*()_+-=[]{}|;':\",./<>?" + + def test_base64_encode_with_unicode(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with unicode characters. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + value = {"Fn::Base64": "Hello 世界 🌍"} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == "Hello 世界 🌍" + + def test_base64_encode_long_string(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with a longer string. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + long_string = "A" * 1000 + value = {"Fn::Base64": long_string} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == long_string + + def test_base64_encode_json_like_string(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with JSON-like string content. + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + + This is a common use case in CloudFormation for encoding user data scripts. + """ + json_string = '{"key": "value", "number": 123}' + value = {"Fn::Base64": json_string} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == json_string + + def test_base64_encode_script_content(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with script content (common EC2 UserData use case). + + Requirement 10.8: Support Fn::Base64 for base64 encoding strings + """ + script = """#!/bin/bash +echo "Hello World" +yum update -y +""" + value = {"Fn::Base64": script} + result = resolver.resolve(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == script + + +class TestFnBase64ResolverErrorHandling: + """Tests for Fn::Base64 error handling. + + Fn::Base64 should raise InvalidTemplateException for non-string inputs. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnBase64Resolver: + """Create a FnBase64Resolver for testing.""" + return FnBase64Resolver(context, None) + + def test_non_string_integer_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with integer raises InvalidTemplateException.""" + value = {"Fn::Base64": 42} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_non_string_list_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with list raises InvalidTemplateException.""" + value = {"Fn::Base64": [1, 2, 3]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_non_string_dict_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with dict raises InvalidTemplateException. + + Note: A dict that is not an intrinsic function should raise an error. + """ + value = {"Fn::Base64": {"key": "value"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_non_string_none_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with None raises InvalidTemplateException.""" + value = {"Fn::Base64": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_non_string_boolean_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with boolean raises InvalidTemplateException.""" + value = {"Fn::Base64": True} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_non_string_float_raises_exception(self, resolver: FnBase64Resolver): + """Test Fn::Base64 with float raises InvalidTemplateException.""" + value = {"Fn::Base64": 3.14} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_error_message_exact_format(self, resolver: FnBase64Resolver): + """Test that error message matches exact expected format. + + The error message must be exactly "Fn::Base64 layout is incorrect" to match + the Kotlin implementation's error messages. + """ + value = {"Fn::Base64": 123} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + # Verify exact error message format + assert str(exc_info.value) == "Fn::Base64 layout is incorrect" + + +class MockStringResolver(IntrinsicFunctionResolver): + """A mock resolver that returns a string for testing nested resolution.""" + + FUNCTION_NAMES = ["Fn::MockString"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return the argument as-is (for testing).""" + return self.get_function_args(value) + + +class MockRefResolver(IntrinsicFunctionResolver): + """A mock resolver that resolves Ref to parameter values for testing.""" + + FUNCTION_NAMES = ["Ref"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Resolve Ref to parameter values from context.""" + ref_target = self.get_function_args(value) + + # Check parameter_values in context + if ref_target in self.context.parameter_values: + return self.context.parameter_values[ref_target] + + # Return the Ref unchanged if not found + return value + + +class TestFnBase64ResolverNestedIntrinsics: + """Tests for Fn::Base64 with nested intrinsic functions. + + Fn::Base64 should resolve nested intrinsics before encoding. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnBase64Resolver and MockStringResolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnBase64Resolver) + orchestrator.register_resolver(MockStringResolver) + return orchestrator + + def test_nested_intrinsic_resolved_first(self, orchestrator: IntrinsicResolver): + """Test that nested intrinsic is resolved before base64 encoding.""" + value = {"Fn::Base64": {"Fn::MockString": "hello"}} + result = orchestrator.resolve_value(value) + + # "hello" in base64 is "aGVsbG8=" + assert result == "aGVsbG8=" + + def test_nested_intrinsic_empty_string(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to empty string.""" + value = {"Fn::Base64": {"Fn::MockString": ""}} + result = orchestrator.resolve_value(value) + + assert result == "" + + def test_nested_intrinsic_non_string_raises_exception(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to non-string raises exception.""" + value = {"Fn::Base64": {"Fn::MockString": 123}} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Fn::Base64 layout is incorrect" in str(exc_info.value) + + def test_nested_ref_to_parameter(self): + """Test Fn::Base64 with nested Ref to a parameter.""" + context = TemplateProcessingContext(fragment={"Resources": {}}, parameter_values={"MyParam": "parameter value"}) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnBase64Resolver) + orchestrator.register_resolver(MockRefResolver) + + value = {"Fn::Base64": {"Ref": "MyParam"}} + result = orchestrator.resolve_value(value) + + # Verify by decoding + decoded = base64.b64decode(result).decode("utf-8") + assert decoded == "parameter value" + + +class TestFnBase64ResolverWithOrchestrator: + """Tests for FnBase64Resolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnBase64Resolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnBase64Resolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Base64 through the orchestrator.""" + value = {"Fn::Base64": "hello"} + result = orchestrator.resolve_value(value) + + assert result == "aGVsbG8=" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Base64 in a nested template structure.""" + value = { + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": {"UserData": {"Fn::Base64": "#!/bin/bash\necho hello"}}, + } + } + } + result = orchestrator.resolve_value(value) + + user_data = result["Resources"]["MyInstance"]["Properties"]["UserData"] + decoded = base64.b64decode(user_data).decode("utf-8") + assert decoded == "#!/bin/bash\necho hello" + + def test_resolve_multiple_fn_base64(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Base64 in same structure.""" + value = { + "first": {"Fn::Base64": "one"}, + "second": {"Fn::Base64": "two"}, + "third": {"Fn::Base64": "three"}, + } + result = orchestrator.resolve_value(value) + + assert base64.b64decode(result["first"]).decode("utf-8") == "one" + assert base64.b64decode(result["second"]).decode("utf-8") == "two" + assert base64.b64decode(result["third"]).decode("utf-8") == "three" + + def test_fn_base64_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Base64 inside a list.""" + value = [ + {"Fn::Base64": "a"}, + {"Fn::Base64": "b"}, + {"Fn::Base64": "c"}, + ] + result = orchestrator.resolve_value(value) + + decoded = [base64.b64decode(r).decode("utf-8") for r in result] + assert decoded == ["a", "b", "c"] + + +class TestFnBase64ResolverPartialMode: + """Tests for FnBase64Resolver in partial resolution mode. + + Fn::Base64 should always be resolved, even in partial mode. + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnBase64Resolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnBase64Resolver) + return orchestrator + + def test_fn_base64_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Base64 is resolved even in partial mode.""" + value = {"Fn::Base64": "hello"} + result = orchestrator.resolve_value(value) + + assert result == "aGVsbG8=" + + def test_fn_base64_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::Base64 alongside preserved intrinsics in partial mode.""" + value = { + "encoded": {"Fn::Base64": "hello"}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "encoded": "aGVsbG8=", + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +class TestFnBase64ResolverRealWorldScenarios: + """Tests for real-world CloudFormation scenarios using Fn::Base64.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnBase64Resolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnBase64Resolver) + return orchestrator + + def test_ec2_userdata_script(self, orchestrator: IntrinsicResolver): + """Test Fn::Base64 for EC2 UserData script encoding. + + This is the most common use case for Fn::Base64 in CloudFormation. + """ + userdata_script = """#!/bin/bash +yum update -y +yum install -y httpd +systemctl start httpd +systemctl enable httpd +echo "Hello World" > /var/www/html/index.html +""" + + template = { + "Resources": { + "WebServer": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": "ami-12345678", + "InstanceType": "t2.micro", + "UserData": {"Fn::Base64": userdata_script}, + }, + } + } + } + + result = orchestrator.resolve_value(template) + + # Verify the UserData is properly encoded + encoded_userdata = result["Resources"]["WebServer"]["Properties"]["UserData"] + decoded = base64.b64decode(encoded_userdata).decode("utf-8") + assert decoded == userdata_script + + def test_lambda_inline_code(self, orchestrator: IntrinsicResolver): + """Test Fn::Base64 for Lambda inline code encoding.""" + lambda_code = """ +import json + +def handler(event, context): + return { + 'statusCode': 200, + 'body': json.dumps('Hello from Lambda!') + } +""" + + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Runtime": "python3.9", + "Handler": "index.handler", + "Code": {"ZipFile": {"Fn::Base64": lambda_code}}, + }, + } + } + } + + result = orchestrator.resolve_value(template) + + # Verify the code is properly encoded + encoded_code = result["Resources"]["MyFunction"]["Properties"]["Code"]["ZipFile"] + decoded = base64.b64decode(encoded_code).decode("utf-8") + assert decoded == lambda_code + + def test_launch_template_userdata(self, orchestrator: IntrinsicResolver): + """Test Fn::Base64 for Launch Template UserData.""" + userdata = "#!/bin/bash\necho 'Starting instance...'" + + template = { + "Resources": { + "MyLaunchTemplate": { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": {"LaunchTemplateData": {"UserData": {"Fn::Base64": userdata}}}, + } + } + } + + result = orchestrator.resolve_value(template) + + encoded = result["Resources"]["MyLaunchTemplate"]["Properties"]["LaunchTemplateData"]["UserData"] + decoded = base64.b64decode(encoded).decode("utf-8") + assert decoded == userdata diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py new file mode 100644 index 00000000000..9bff59d2730 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py @@ -0,0 +1,2080 @@ +""" +Unit tests for the FnFindInMapResolver class. + +Tests cover: +- Basic Fn::FindInMap functionality with valid map lookups +- DefaultValue fallback when keys are not found +- Nested intrinsic function resolution in keys +- Error handling for invalid layouts and missing keys +- Integration with IntrinsicResolver orchestrator + +Requirements: + - 5.1: WHEN Fn::FindInMap is applied with valid map name, top-level key, and + second-level key, THEN THE Resolver SHALL return the corresponding + value from the Mappings section + - 5.2: WHEN Fn::FindInMap includes a DefaultValue option and the top-level key + is not found, THEN THE Resolver SHALL return the default value + - 5.3: WHEN Fn::FindInMap includes a DefaultValue option and the second-level key + is not found, THEN THE Resolver SHALL return the default value + - 5.4: WHEN Fn::FindInMap keys contain nested intrinsic functions (Fn::Select, + Fn::Split, Fn::If, Fn::Join, Fn::Sub), THEN THE Resolver SHALL resolve + those intrinsics before performing the lookup + - 5.5: WHEN Fn::FindInMap is applied with an invalid layout, THEN THE Resolver + SHALL raise an Invalid_Template_Exception + - 5.6: WHEN Fn::FindInMap lookup fails without a DefaultValue, THEN THE Resolver + SHALL raise an Invalid_Template_Exception +""" + +import pytest +from typing import Any, Dict, Optional + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_find_in_map import FnFindInMapResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +# ============================================================================= +# Test Fixtures and Helper Classes +# ============================================================================= + + +class MockRefResolver(IntrinsicFunctionResolver): + """A mock resolver that resolves Ref to parameter values for testing.""" + + FUNCTION_NAMES = ["Ref"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Resolve Ref to parameter values from context.""" + ref_target = self.get_function_args(value) + + # Check parameter_values in context + if ref_target in self.context.parameter_values: + return self.context.parameter_values[ref_target] + + # Return the Ref unchanged if not found + return value + + +class MockSelectResolver(IntrinsicFunctionResolver): + """A mock resolver that implements Fn::Select for testing nested intrinsics.""" + + FUNCTION_NAMES = ["Fn::Select"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Select an item from a list by index.""" + args = self.get_function_args(value) + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Select layout is incorrect") + + index = args[0] + items = args[1] + + # Resolve nested intrinsics + if self.parent is not None: + items = self.parent.resolve_value(items) + + if not isinstance(items, list): + raise InvalidTemplateException("Fn::Select layout is incorrect") + + if not isinstance(index, int) or index < 0 or index >= len(items): + raise InvalidTemplateException("Fn::Select index out of bounds") + + return items[index] + + +class MockSplitResolver(IntrinsicFunctionResolver): + """A mock resolver that implements Fn::Split for testing nested intrinsics.""" + + FUNCTION_NAMES = ["Fn::Split"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Split a string by delimiter.""" + args = self.get_function_args(value) + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Split layout is incorrect") + + delimiter = args[0] + source_string = args[1] + + # Resolve nested intrinsics + if self.parent is not None: + source_string = self.parent.resolve_value(source_string) + + if not isinstance(source_string, str): + raise InvalidTemplateException("Fn::Split layout is incorrect") + + return source_string.split(delimiter) + + +# ============================================================================= +# Unit Tests for FnFindInMapResolver.can_resolve() +# ============================================================================= + + +class TestFnFindInMapResolverCanResolve: + """Tests for FnFindInMapResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}, "Mappings": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnFindInMapResolver: + """Create a FnFindInMapResolver for testing.""" + return FnFindInMapResolver(context, None) + + def test_can_resolve_fn_find_in_map(self, resolver: FnFindInMapResolver): + """Test that can_resolve returns True for Fn::FindInMap.""" + value = {"Fn::FindInMap": ["MapName", "TopKey", "SecondKey"]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnFindInMapResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnFindInMapResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnFindInMapResolver): + """Test that FUNCTION_NAMES contains Fn::FindInMap.""" + assert FnFindInMapResolver.FUNCTION_NAMES == ["Fn::FindInMap"] + + +# ============================================================================= +# Unit Tests for Basic Fn::FindInMap Functionality +# ============================================================================= + + +class TestFnFindInMapResolverBasicFunctionality: + """Tests for basic Fn::FindInMap functionality. + + Requirement 5.1: WHEN Fn::FindInMap is applied with valid map name, top-level + key, and second-level key, THEN THE Resolver SHALL return the corresponding + value from the Mappings section + """ + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return { + "RegionMap": { + "us-east-1": {"AMI": "ami-12345678", "InstanceType": "t2.micro"}, + "us-west-2": {"AMI": "ami-87654321", "InstanceType": "t2.small"}, + }, + "EnvironmentMap": {"prod": {"Size": "large", "Count": 5}, "dev": {"Size": "small", "Count": 1}}, + } + + @pytest.fixture + def context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnFindInMapResolver: + """Create a FnFindInMapResolver for testing.""" + return FnFindInMapResolver(context, None) + + def test_basic_map_lookup(self, resolver: FnFindInMapResolver): + """Test basic Fn::FindInMap lookup returns correct value. + + Requirement 5.1: Return the corresponding value from the Mappings section + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} + result = resolver.resolve(value) + assert result == "ami-12345678" + + def test_lookup_different_keys(self, resolver: FnFindInMapResolver): + """Test Fn::FindInMap with different keys. + + Requirement 5.1: Return the corresponding value from the Mappings section + """ + value = {"Fn::FindInMap": ["RegionMap", "us-west-2", "InstanceType"]} + result = resolver.resolve(value) + assert result == "t2.small" + + def test_lookup_different_map(self, resolver: FnFindInMapResolver): + """Test Fn::FindInMap with different map. + + Requirement 5.1: Return the corresponding value from the Mappings section + """ + value = {"Fn::FindInMap": ["EnvironmentMap", "prod", "Size"]} + result = resolver.resolve(value) + assert result == "large" + + def test_lookup_returns_integer(self, resolver: FnFindInMapResolver): + """Test Fn::FindInMap can return integer values. + + Requirement 5.1: Return the corresponding value from the Mappings section + """ + value = {"Fn::FindInMap": ["EnvironmentMap", "prod", "Count"]} + result = resolver.resolve(value) + assert result == 5 + + +# ============================================================================= +# Unit Tests for DefaultValue Fallback +# ============================================================================= + + +class TestFnFindInMapResolverDefaultValue: + """Tests for Fn::FindInMap DefaultValue fallback. + + Requirement 5.2: WHEN Fn::FindInMap includes a DefaultValue option and the + top-level key is not found, THEN THE Resolver SHALL return the default value + + Requirement 5.3: WHEN Fn::FindInMap includes a DefaultValue option and the + second-level key is not found, THEN THE Resolver SHALL return the default value + """ + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}} + + @pytest.fixture + def context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnFindInMapResolver: + """Create a FnFindInMapResolver for testing.""" + return FnFindInMapResolver(context, None) + + def test_default_value_when_map_not_found(self, resolver: FnFindInMapResolver): + """Test DefaultValue returned when map name not found. + + Requirement 5.2: Return default value when top-level key not found + """ + value = {"Fn::FindInMap": ["NonExistentMap", "key1", "key2", {"DefaultValue": "fallback-value"}]} + result = resolver.resolve(value) + assert result == "fallback-value" + + def test_default_value_when_top_level_key_not_found(self, resolver: FnFindInMapResolver): + """Test DefaultValue returned when top-level key not found. + + Requirement 5.2: Return default value when top-level key not found + """ + value = {"Fn::FindInMap": ["RegionMap", "invalid-region", "AMI", {"DefaultValue": "ami-default"}]} + result = resolver.resolve(value) + assert result == "ami-default" + + def test_default_value_when_second_level_key_not_found(self, resolver: FnFindInMapResolver): + """Test DefaultValue returned when second-level key not found. + + Requirement 5.3: Return default value when second-level key not found + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "NonExistentKey", {"DefaultValue": "default-value"}]} + result = resolver.resolve(value) + assert result == "default-value" + + def test_default_value_can_be_dict(self, resolver: FnFindInMapResolver): + """Test DefaultValue can be a dictionary.""" + value = {"Fn::FindInMap": ["RegionMap", "invalid", "key", {"DefaultValue": {"nested": "object"}}]} + result = resolver.resolve(value) + assert result == {"nested": "object"} + + def test_default_value_can_be_list(self, resolver: FnFindInMapResolver): + """Test DefaultValue can be a list.""" + value = {"Fn::FindInMap": ["RegionMap", "invalid", "key", {"DefaultValue": [1, 2, 3]}]} + result = resolver.resolve(value) + assert result == [1, 2, 3] + + def test_default_value_can_be_integer(self, resolver: FnFindInMapResolver): + """Test DefaultValue can be an integer.""" + value = {"Fn::FindInMap": ["RegionMap", "invalid", "key", {"DefaultValue": 42}]} + result = resolver.resolve(value) + assert result == 42 + + def test_default_value_can_be_null(self, resolver: FnFindInMapResolver): + """Test DefaultValue can be null.""" + value = {"Fn::FindInMap": ["RegionMap", "invalid", "key", {"DefaultValue": None}]} + result = resolver.resolve(value) + assert result is None + + def test_successful_lookup_ignores_default_value(self, resolver: FnFindInMapResolver): + """Test that successful lookup returns mapped value, not default. + + Requirement 5.1: Return the corresponding value from the Mappings section + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI", {"DefaultValue": "should-not-be-returned"}]} + result = resolver.resolve(value) + assert result == "ami-12345678" + + +# ============================================================================= +# Unit Tests for Error Handling +# ============================================================================= + + +class TestFnFindInMapResolverErrorHandling: + """Tests for Fn::FindInMap error handling. + + Requirement 5.5: WHEN Fn::FindInMap is applied with an invalid layout, THEN THE + Resolver SHALL raise an Invalid_Template_Exception + + Requirement 5.6: WHEN Fn::FindInMap lookup fails without a DefaultValue, THEN THE + Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}} + + @pytest.fixture + def context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnFindInMapResolver: + """Create a FnFindInMapResolver for testing.""" + return FnFindInMapResolver(context, None) + + def test_invalid_layout_not_a_list(self, resolver: FnFindInMapResolver): + """Test error when argument is not a list. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::FindInMap": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_invalid_layout_too_few_elements(self, resolver: FnFindInMapResolver): + """Test error when list has fewer than 3 elements. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::FindInMap": ["MapName", "TopKey"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_invalid_layout_empty_list(self, resolver: FnFindInMapResolver): + """Test error when list is empty. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value: Dict[str, Any] = {"Fn::FindInMap": []} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_map_not_found_without_default(self, resolver: FnFindInMapResolver): + """Test error when map not found and no DefaultValue. + + Requirement 5.6: Raise Invalid_Template_Exception when lookup fails without DefaultValue + """ + value = {"Fn::FindInMap": ["NonExistentMap", "key1", "key2"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "cannot find map" in str(exc_info.value).lower() + + def test_top_level_key_not_found_without_default(self, resolver: FnFindInMapResolver): + """Test error when top-level key not found and no DefaultValue. + + Requirement 5.6: Raise Invalid_Template_Exception when lookup fails without DefaultValue + """ + value = {"Fn::FindInMap": ["RegionMap", "invalid-region", "AMI"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "cannot find key" in str(exc_info.value).lower() + + def test_second_level_key_not_found_without_default(self, resolver: FnFindInMapResolver): + """Test error when second-level key not found and no DefaultValue. + + Requirement 5.6: Raise Invalid_Template_Exception when lookup fails without DefaultValue + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "NonExistentKey"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "cannot find key" in str(exc_info.value).lower() + + def test_non_string_map_name_raises_exception(self, resolver: FnFindInMapResolver): + """Test error when map name is not a string. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::FindInMap": [123, "TopKey", "SecondKey"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_non_string_top_key_raises_exception(self, resolver: FnFindInMapResolver): + """Test error when top-level key is not a string. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::FindInMap": ["RegionMap", 123, "SecondKey"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_non_string_second_key_raises_exception(self, resolver: FnFindInMapResolver): + """Test error when second-level key is not a string. + + Requirement 5.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", 123]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + +# ============================================================================= +# Unit Tests for Nested Intrinsic Resolution +# ============================================================================= + + +class TestFnFindInMapResolverNestedIntrinsics: + """Tests for Fn::FindInMap with nested intrinsic functions. + + Requirement 5.4: WHEN Fn::FindInMap keys contain nested intrinsic functions + (Fn::Select, Fn::Split, Fn::If, Fn::Join, Fn::Sub), THEN THE Resolver SHALL + resolve those intrinsics before performing the lookup + """ + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return { + "RegionMap": { + "us-east-1": {"AMI": "ami-12345678", "InstanceType": "t2.micro"}, + "us-west-2": {"AMI": "ami-87654321", "InstanceType": "t2.small"}, + } + } + + @pytest.fixture + def context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with mappings and parameters.""" + ctx = TemplateProcessingContext( + fragment={"Resources": {}, "Mappings": mappings}, + parameter_values={"Region": "us-east-1", "MapName": "RegionMap", "KeyName": "AMI"}, + ) + ctx.parsed_template = ParsedTemplate( + resources={}, + mappings=mappings, + parameters={"Region": {"Type": "String"}, "MapName": {"Type": "String"}, "KeyName": {"Type": "String"}}, + ) + return ctx + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnFindInMapResolver and mock resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnFindInMapResolver) + orchestrator.register_resolver(MockRefResolver) + orchestrator.register_resolver(MockSelectResolver) + orchestrator.register_resolver(MockSplitResolver) + return orchestrator + + def test_ref_in_map_name(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Ref in map name. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": [{"Ref": "MapName"}, "us-east-1", "AMI"]} + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_ref_in_top_level_key(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Ref in top-level key. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]} + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_ref_in_second_level_key(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Ref in second-level key. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", {"Ref": "KeyName"}]} + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_ref_in_all_keys(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Ref in all keys. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": [{"Ref": "MapName"}, {"Ref": "Region"}, {"Ref": "KeyName"}]} + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_fn_select_in_key(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Fn::Select in key. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": ["RegionMap", {"Fn::Select": [0, ["us-east-1", "us-west-2"]]}, "AMI"]} + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_fn_split_with_fn_select_in_key(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with Fn::Split and Fn::Select in key. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = { + "Fn::FindInMap": ["RegionMap", {"Fn::Select": [1, {"Fn::Split": [",", "us-west-2,us-east-1"]}]}, "AMI"] + } + result = orchestrator.resolve_value(value) + assert result == "ami-12345678" + + def test_nested_intrinsic_in_default_value(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with nested intrinsic in DefaultValue. + + Requirement 5.4: Resolve nested intrinsics before lookup + """ + value = {"Fn::FindInMap": ["RegionMap", "invalid-region", "AMI", {"DefaultValue": {"Ref": "Region"}}]} + result = orchestrator.resolve_value(value) + # DefaultValue is {"Ref": "Region"} which resolves to "us-east-1" + assert result == "us-east-1" + + +# ============================================================================= +# Unit Tests for Integration with IntrinsicResolver Orchestrator +# ============================================================================= + + +class TestFnFindInMapResolverWithOrchestrator: + """Tests for FnFindInMapResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}, "us-west-2": {"AMI": "ami-87654321"}}} + + @pytest.fixture + def context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnFindInMapResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnFindInMapResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::FindInMap through the orchestrator.""" + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} + result = orchestrator.resolve_value(value) + + assert result == "ami-12345678" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::FindInMap in a nested template structure.""" + value = { + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": {"ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}}, + } + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyInstance"]["Properties"]["ImageId"] == "ami-12345678" + + def test_resolve_multiple_fn_find_in_map(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::FindInMap in same structure.""" + value = { + "first": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}, + "second": {"Fn::FindInMap": ["RegionMap", "us-west-2", "AMI"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "first": "ami-12345678", + "second": "ami-87654321", + } + + def test_fn_find_in_map_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap inside a list.""" + value = [ + {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}, + {"Fn::FindInMap": ["RegionMap", "us-west-2", "AMI"]}, + ] + result = orchestrator.resolve_value(value) + + assert result == ["ami-12345678", "ami-87654321"] + + +# ============================================================================= +# Unit Tests for Partial Resolution Mode +# ============================================================================= + + +class TestFnFindInMapResolverPartialMode: + """Tests for FnFindInMapResolver in partial resolution mode. + + Fn::FindInMap should always be resolved, even in partial mode. + """ + + @pytest.fixture + def mappings(self) -> Dict[str, Any]: + """Create sample mappings for testing.""" + return {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}} + + @pytest.fixture + def partial_context(self, mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + ctx = TemplateProcessingContext( + fragment={"Resources": {}, "Mappings": mappings}, + resolution_mode=ResolutionMode.PARTIAL, + ) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnFindInMapResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnFindInMapResolver) + return orchestrator + + def test_fn_find_in_map_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::FindInMap is resolved even in partial mode. + + Requirement 16.4: In partial mode, still resolve Fn::FindInMap + """ + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} + result = orchestrator.resolve_value(value) + + assert result == "ami-12345678" + + def test_fn_find_in_map_with_default_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap with DefaultValue in partial mode.""" + value = {"Fn::FindInMap": ["RegionMap", "invalid-region", "AMI", {"DefaultValue": "ami-default"}]} + result = orchestrator.resolve_value(value) + + assert result == "ami-default" + + def test_fn_find_in_map_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::FindInMap alongside preserved intrinsics in partial mode.""" + value = { + "ami": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "ami": "ami-12345678", + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +# ============================================================================= +# Unit Tests for Edge Cases +# ============================================================================= + + +class TestFnFindInMapResolverEdgeCases: + """Tests for Fn::FindInMap edge cases.""" + + @pytest.fixture + def context_with_empty_mappings(self) -> TemplateProcessingContext: + """Create a context with empty mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": {}}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings={}) + return ctx + + @pytest.fixture + def context_without_parsed_template(self) -> TemplateProcessingContext: + """Create a context without parsed template (uses fragment).""" + return TemplateProcessingContext( + fragment={"Resources": {}, "Mappings": {"TestMap": {"key1": {"key2": "value"}}}} + ) + + def test_empty_mappings_with_default(self, context_with_empty_mappings: TemplateProcessingContext): + """Test Fn::FindInMap with empty mappings returns DefaultValue.""" + resolver = FnFindInMapResolver(context_with_empty_mappings, None) + value = {"Fn::FindInMap": ["AnyMap", "anyKey", "anyKey2", {"DefaultValue": "fallback"}]} + result = resolver.resolve(value) + assert result == "fallback" + + def test_empty_mappings_without_default_raises(self, context_with_empty_mappings: TemplateProcessingContext): + """Test Fn::FindInMap with empty mappings raises without DefaultValue.""" + resolver = FnFindInMapResolver(context_with_empty_mappings, None) + value = {"Fn::FindInMap": ["AnyMap", "anyKey", "anyKey2"]} + + with pytest.raises(InvalidTemplateException): + resolver.resolve(value) + + def test_uses_fragment_when_no_parsed_template(self, context_without_parsed_template: TemplateProcessingContext): + """Test Fn::FindInMap uses fragment when parsed_template is None.""" + resolver = FnFindInMapResolver(context_without_parsed_template, None) + value = {"Fn::FindInMap": ["TestMap", "key1", "key2"]} + result = resolver.resolve(value) + assert result == "value" + + def test_map_value_can_be_complex_object(self): + """Test Fn::FindInMap can return complex objects.""" + mappings = {"ComplexMap": {"key1": {"key2": {"nested": {"deeply": "nested"}, "list": [1, 2, 3]}}}} + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + + resolver = FnFindInMapResolver(ctx, None) + value = {"Fn::FindInMap": ["ComplexMap", "key1", "key2"]} + result = resolver.resolve(value) + + assert result == {"nested": {"deeply": "nested"}, "list": [1, 2, 3]} + + def test_fourth_argument_invalid_type_raises(self): + """Test that invalid 4th argument type raises exception.""" + mappings = {"TestMap": {"key1": {"key2": "value"}}} + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + + resolver = FnFindInMapResolver(ctx, None) + # 4th argument is a string instead of dict with DefaultValue + value = {"Fn::FindInMap": ["TestMap", "key1", "key2", "invalid"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + + def test_fourth_argument_dict_without_default_value_is_valid(self): + """Test that 4th argument dict without DefaultValue key is treated as no default. + + When the 4th argument is a dict but doesn't contain "DefaultValue" key, + it's treated as if no default was provided. The lookup proceeds normally + and fails if the key is not found. + """ + mappings = {"TestMap": {"key1": {"key2": "value"}}} + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + + resolver = FnFindInMapResolver(ctx, None) + # 4th argument is a dict but without DefaultValue key - treated as no default + value = {"Fn::FindInMap": ["TestMap", "invalid", "key2", {"SomeOtherKey": "value"}]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + # Should fail because lookup fails and no DefaultValue was provided + assert "cannot find key" in str(exc_info.value).lower() + + +# ============================================================================= +# Property-Based Tests for Fn::FindInMap Default Value Fallback +# ============================================================================= + + +# ============================================================================= +# Parametrized Tests for Fn::FindInMap Default Value Fallback +# ============================================================================= + + +class TestFnFindInMapDefaultValueFallbackPropertyTests: + """ + Parametrized tests for Fn::FindInMap Default Value Fallback. + + Feature: cfn-language-extensions-python, Property 7: Fn::FindInMap Default Value Fallback + + These tests validate that for any Fn::FindInMap with a DefaultValue option + where either the top-level key or second-level key is not found in the mappings, + the resolver SHALL return the default value. + + **Validates: Requirements 5.2, 5.3** + """ + + @staticmethod + def _create_context_with_mappings(mappings: Dict[str, Any]) -> TemplateProcessingContext: + """Create a template processing context with the given mappings.""" + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + return ctx + + @staticmethod + def _create_orchestrator(context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnFindInMapResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnFindInMapResolver) + return orchestrator + + @pytest.mark.parametrize( + "existing_map_name, existing_top_key, existing_second_key, existing_value, nonexistent_map_name, default_value", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "MissingMap", "fallback-ami"), + ("EnvConfig", "prod", "Size", "large", "OtherConfig", 42), + ("Settings", "alpha", "key1", "val1", "NoSuchMap", None), + ], + ) + def test_default_value_returned_when_map_not_found( + self, + existing_map_name, + existing_top_key, + existing_second_key, + existing_value, + nonexistent_map_name, + default_value, + ): + """ + Property 7: For any Fn::FindInMap with a DefaultValue option where the map name + is not found, the resolver SHALL return the default value. + + **Validates: Requirements 5.2** + """ + mappings = {existing_map_name: {existing_top_key: {existing_second_key: existing_value}}} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = { + "Fn::FindInMap": [ + nonexistent_map_name, + existing_top_key, + existing_second_key, + {"DefaultValue": default_value}, + ] + } + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, existing_top_key, existing_second_key, existing_value, nonexistent_top_key, default_value", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "eu-west-1", "ami-default"), + ("EnvConfig", "prod", "Size", "large", "staging", {"nested": "default"}), + ("Settings", "alpha", "key1", "val1", "beta", [1, 2, 3]), + ], + ) + def test_default_value_returned_when_top_level_key_not_found( + self, map_name, existing_top_key, existing_second_key, existing_value, nonexistent_top_key, default_value + ): + """ + Property 7: For any Fn::FindInMap with a DefaultValue option where the top-level + key is not found, the resolver SHALL return the default value. + + **Validates: Requirements 5.2** + """ + mappings = {map_name: {existing_top_key: {existing_second_key: existing_value}}} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, nonexistent_top_key, existing_second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, top_key, existing_second_key, existing_value, nonexistent_second_key, default_value", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "InstanceType", "t2.micro"), + ("EnvConfig", "prod", "Size", "large", "Color", None), + ("Settings", "alpha", "key1", "val1", "key2", True), + ], + ) + def test_default_value_returned_when_second_level_key_not_found( + self, map_name, top_key, existing_second_key, existing_value, nonexistent_second_key, default_value + ): + """ + Property 7: For any Fn::FindInMap with a DefaultValue option where the second-level + key is not found, the resolver SHALL return the default value. + + **Validates: Requirements 5.3** + """ + mappings = {map_name: {top_key: {existing_second_key: existing_value}}} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, nonexistent_second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, default_value", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "should-not-return"), + ("EnvConfig", "prod", "Size", "large", 999), + ("Settings", "alpha", "key1", "val1", None), + ], + ) + def test_mapped_value_returned_when_all_keys_found( + self, map_name, top_key, second_key, mapped_value, default_value + ): + """ + Property 7: For any Fn::FindInMap with a DefaultValue option where all keys + are found, the resolver SHALL return the mapped value, NOT the default. + + **Validates: Requirements 5.1** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "default_value", + [ + "string-default", + 42, + {"nested": "dict", "key": "value"}, + [1, 2, 3], + None, + ], + ) + def test_default_value_supports_various_types(self, default_value): + """ + Property 7: The default value can be of any JSON-compatible type. + + **Validates: Requirements 5.2, 5.3** + """ + mappings: Dict[str, Any] = {} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": ["NonExistentMap", "key1", "key2", {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, default_value", + [ + ("MyMap", "topKey", "secondKey", "fallback"), + ("Config", "env", "setting", 0), + ("Data", "region", "value", {"a": 1}), + ], + ) + def test_default_value_with_empty_mappings(self, map_name, top_key, second_key, default_value): + """ + Property 7: When the Mappings section is empty, the resolver SHALL return + the default value. + + **Validates: Requirements 5.2** + """ + mappings: Dict[str, Any] = {} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value", + [ + ("Config", "env", "settings", {"nested": {"deep": "value"}}), + ("Data", "region", "list", [1, "two", True]), + ("Simple", "k1", "k2", "plain-string"), + ], + ) + def test_complex_mapped_values_returned_correctly(self, map_name, top_key, second_key, mapped_value): + """ + Property 7: Complex mapped values (dicts, lists) are returned correctly. + + **Validates: Requirements 5.1** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, second_key]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_keys, second_keys, default_value", + [ + ("Config", ["prod", "dev"], ["size", "count"], "default-val"), + ("RegionMap", ["us-east-1", "eu-west-1", "ap-south-1"], ["AMI", "Type"], None), + ], + ) + def test_default_value_with_multiple_keys_in_map(self, map_name, top_keys, second_keys, default_value): + """ + Property 7: When the map has multiple keys but the requested key is not found, + the resolver SHALL return the default value. + + **Validates: Requirements 5.2, 5.3** + """ + mappings = { + map_name: { + top_key: {second_key: f"value_{top_key}_{second_key}" for second_key in second_keys} + for top_key in top_keys + } + } + context = self._create_context_with_mappings(mappings) + orchestrator = self._create_orchestrator(context) + + nonexistent_top_key = "nonexistent_" + top_keys[0] + value = {"Fn::FindInMap": [map_name, nonexistent_top_key, second_keys[0], {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + +# ============================================================================= +# Parametrized Tests for Fn::FindInMap Key Resolution +# ============================================================================= + + +class TestFnFindInMapKeyResolutionPropertyTests: + """ + Parametrized tests for Fn::FindInMap Key Resolution. + + Feature: cfn-language-extensions-python, Property 8: Fn::FindInMap Key Resolution + + These tests validate that for any Fn::FindInMap where keys contain nested + intrinsic functions, the resolver SHALL resolve those intrinsics before + performing the map lookup. + + **Validates: Requirements 5.4** + """ + + @staticmethod + def _create_context_with_mappings_and_params( + mappings: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + ) -> TemplateProcessingContext: + """Create a template processing context with mappings and parameters.""" + ctx = TemplateProcessingContext( + fragment={"Resources": {}, "Mappings": mappings}, + parameter_values=parameter_values or {}, + ) + ctx.parsed_template = ParsedTemplate( + resources={}, + mappings=mappings, + parameters={k: {"Type": "String"} for k in (parameter_values or {}).keys()}, + ) + return ctx + + @staticmethod + def _create_orchestrator(context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnFindInMapResolver and mock resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnFindInMapResolver) + orchestrator.register_resolver(MockRefResolver) + orchestrator.register_resolver(MockSelectResolver) + orchestrator.register_resolver(MockSplitResolver) + return orchestrator + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, param_name", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "MapParam"), + ("EnvConfig", "prod", "Size", "large", "ConfigName"), + ("Settings", "alpha", "key1", "val1", "SettingsMap"), + ], + ) + def test_ref_in_map_name_is_resolved_before_lookup(self, map_name, top_key, second_key, mapped_value, param_name): + """ + Property 8: When the map name contains a Ref, the resolver SHALL resolve + the Ref before performing the map lookup. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + parameter_values = {param_name: map_name} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [{"Ref": param_name}, top_key, second_key]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, param_name", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "Region"), + ("EnvConfig", "prod", "Size", "large", "Environment"), + ("Settings", "alpha", "key1", "val1", "Level"), + ], + ) + def test_ref_in_top_level_key_is_resolved_before_lookup( + self, map_name, top_key, second_key, mapped_value, param_name + ): + """ + Property 8: When the top-level key contains a Ref, the resolver SHALL + resolve the Ref before performing the lookup. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + parameter_values = {param_name: top_key} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, {"Ref": param_name}, second_key]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, param_name", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "KeyName"), + ("EnvConfig", "prod", "Size", "large", "Property"), + ("Settings", "alpha", "key1", "val1", "Field"), + ], + ) + def test_ref_in_second_level_key_is_resolved_before_lookup( + self, map_name, top_key, second_key, mapped_value, param_name + ): + """ + Property 8: When the second-level key contains a Ref, the resolver SHALL + resolve the Ref before performing the lookup. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + parameter_values = {param_name: second_key} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, {"Ref": param_name}]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, map_param, top_param, second_param", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "MapP", "TopP", "SecondP"), + ("EnvConfig", "prod", "Size", "large", "CfgName", "EnvName", "PropName"), + ], + ) + def test_ref_in_all_keys_is_resolved_before_lookup( + self, map_name, top_key, second_key, mapped_value, map_param, top_param, second_param + ): + """ + Property 8: When all keys contain Ref intrinsics, the resolver SHALL + resolve all Refs before performing the map lookup. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + parameter_values = {map_param: map_name, top_param: top_key, second_param: second_key} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [{"Ref": map_param}, {"Ref": top_param}, {"Ref": second_param}]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_keys, second_key, mapped_value, select_index", + [ + ("RegionMap", ["us-east-1", "us-west-2", "eu-west-1"], "AMI", "ami-selected", 0), + ("Config", ["prod", "dev"], "Size", "large", 1), + ], + ) + def test_fn_select_in_top_level_key_is_resolved_before_lookup( + self, map_name, top_keys, second_key, mapped_value, select_index + ): + """ + Property 8: When the top-level key contains Fn::Select, the resolver + SHALL resolve it before lookup. + + **Validates: Requirements 5.4** + """ + selected_top_key = top_keys[select_index] + mappings = {map_name: {top_key: {second_key: f"value_for_{top_key}"} for top_key in top_keys}} + mappings[map_name][selected_top_key][second_key] = mapped_value + + context = self._create_context_with_mappings_and_params(mappings, {}) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, {"Fn::Select": [select_index, top_keys]}, second_key]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_keys, mapped_value, select_index", + [ + ("RegionMap", "us-east-1", ["AMI", "Type", "Size"], "ami-12345", 0), + ("Config", "prod", ["key1", "key2"], "selected-val", 1), + ], + ) + def test_fn_select_in_second_level_key_is_resolved_before_lookup( + self, map_name, top_key, second_keys, mapped_value, select_index + ): + """ + Property 8: When the second-level key contains Fn::Select, the resolver + SHALL resolve it before lookup. + + **Validates: Requirements 5.4** + """ + selected_second_key = second_keys[select_index] + mappings = {map_name: {top_key: {sk: f"value_for_{sk}" for sk in second_keys}}} + mappings[map_name][top_key][selected_second_key] = mapped_value + + context = self._create_context_with_mappings_and_params(mappings, {}) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, top_key, {"Fn::Select": [select_index, second_keys]}]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, delimiter", + [ + ("RegionMap", "useast1", "AMI", "ami-12345", ","), + ("Config", "prod", "Size", "large", ":"), + ("Settings", "alpha", "key1", "val1", "/"), + ], + ) + def test_fn_split_with_fn_select_in_key_is_resolved_before_lookup( + self, map_name, top_key, second_key, mapped_value, delimiter + ): + """ + Property 8: When a key contains nested Fn::Split and Fn::Select, the + resolver SHALL resolve both before lookup. + + **Validates: Requirements 5.4** + """ + delimited_string = delimiter.join(["other1", top_key, "other2"]) + mappings = {map_name: {top_key: {second_key: mapped_value}}} + + context = self._create_context_with_mappings_and_params(mappings, {}) + orchestrator = self._create_orchestrator(context) + + value = { + "Fn::FindInMap": [map_name, {"Fn::Select": [1, {"Fn::Split": [delimiter, delimited_string]}]}, second_key] + } + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, default_value, param_name", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "ami-default", "Region"), + ("Config", "prod", "Size", "large", "small", "Env"), + ], + ) + def test_ref_in_key_with_default_value_resolves_correctly( + self, map_name, top_key, second_key, mapped_value, default_value, param_name + ): + """ + Property 8: When keys have nested intrinsics AND a DefaultValue is provided, + the resolver SHALL resolve intrinsics and return the mapped value. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {top_key: {second_key: mapped_value}}} + parameter_values = {param_name: top_key} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, {"Ref": param_name}, second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == mapped_value + + @pytest.mark.parametrize( + "map_name, existing_top_key, second_key, mapped_value, default_value, nonexistent_key, param_name", + [ + ("RegionMap", "us-east-1", "AMI", "ami-12345", "ami-default", "eu-west-1", "Region"), + ("Config", "prod", "Size", "large", "medium", "staging", "Env"), + ], + ) + def test_ref_resolving_to_nonexistent_key_returns_default( + self, map_name, existing_top_key, second_key, mapped_value, default_value, nonexistent_key, param_name + ): + """ + Property 8: When a Ref in a key resolves to a non-existent key and a + DefaultValue is provided, the resolver SHALL return the default value. + + **Validates: Requirements 5.4** + """ + mappings = {map_name: {existing_top_key: {second_key: mapped_value}}} + parameter_values = {param_name: nonexistent_key} + context = self._create_context_with_mappings_and_params(mappings, parameter_values) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::FindInMap": [map_name, {"Ref": param_name}, second_key, {"DefaultValue": default_value}]} + result = orchestrator.resolve_value(value) + assert result == default_value + + @pytest.mark.parametrize( + "map_name, top_key, second_key, mapped_value, delimiter", + [ + ("RegionMap", "useast1", "AMI", "ami-12345", ","), + ("Config", "prod", "Size", "large", "/"), + ], + ) + def test_multiple_nested_intrinsics_in_different_keys(self, map_name, top_key, second_key, mapped_value, delimiter): + """ + Property 8: When different keys contain different nested intrinsic functions, + the resolver SHALL resolve all of them correctly. + + **Validates: Requirements 5.4** + """ + second_keys = [second_key, "other1", "other2"] + delimited_string = delimiter.join(["prefix", top_key, "suffix"]) + mappings = {map_name: {top_key: {second_key: mapped_value}}} + + context = self._create_context_with_mappings_and_params(mappings, {}) + orchestrator = self._create_orchestrator(context) + + value = { + "Fn::FindInMap": [ + map_name, + {"Fn::Select": [1, {"Fn::Split": [delimiter, delimited_string]}]}, + {"Fn::Select": [0, second_keys]}, + ] + } + result = orchestrator.resolve_value(value) + assert result == mapped_value + + +class TestFnFindInMapWithDefaultValueIntegration: + """ + Unit tests for Fn::FindInMap with DefaultValue integration scenarios. + + These tests validate the integration of Fn::FindInMap with DefaultValue + in various SAM CLI scenarios including sam validate and sam build. + + **Validates: Requirements 5A.1, 5A.2, 5A.3, 5A.4** + """ + + def test_map_lookup_returns_value_when_key_exists(self): + """ + Test that Fn::FindInMap returns the mapped value when the key exists. + + **Validates: Requirements 5A.1** + + WHEN a template uses Fn::FindInMap with a DefaultValue option, + THE SAM_CLI SHALL return the mapped value if the key exists. + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "RegionConfig": { + "us-east-1": {"InstanceType": "t2.micro", "AMI": "ami-12345"}, + "us-west-2": {"InstanceType": "t2.small", "AMI": "ami-67890"}, + } + }, + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": { + "Fn::FindInMap": [ + "RegionConfig", + "us-east-1", + "InstanceType", + {"DefaultValue": "t2.nano"}, + ] + } + }, + } + }, + } + + result = process_template(template) + + # Should return the mapped value, not the default + assert result["Resources"]["MyInstance"]["Properties"]["InstanceType"] == "t2.micro" + + def test_map_lookup_returns_default_value_when_key_does_not_exist(self): + """ + Test that Fn::FindInMap returns DefaultValue when the key does not exist. + + **Validates: Requirements 5A.2** + + WHEN a template uses Fn::FindInMap with a DefaultValue option and the key + does NOT exist, THE SAM_CLI SHALL return the DefaultValue. + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "RegionConfig": { + "us-east-1": {"InstanceType": "t2.micro"}, + } + }, + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + # Region eu-west-1 does not exist in mappings + "InstanceType": { + "Fn::FindInMap": [ + "RegionConfig", + "eu-west-1", + "InstanceType", + {"DefaultValue": "t2.nano"}, + ] + } + }, + } + }, + } + + result = process_template(template) + + # Should return the default value since eu-west-1 doesn't exist + assert result["Resources"]["MyInstance"]["Properties"]["InstanceType"] == "t2.nano" + + def test_map_lookup_returns_default_when_second_level_key_missing(self): + """ + Test that Fn::FindInMap returns DefaultValue when second-level key is missing. + + **Validates: Requirements 5A.2** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "RegionConfig": { + "us-east-1": {"InstanceType": "t2.micro"}, + # AMI key is missing for us-east-1 + } + }, + "Resources": { + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "RegionConfig", + "us-east-1", + "AMI", # This key doesn't exist + {"DefaultValue": "ami-default"}, + ] + } + }, + } + }, + } + + result = process_template(template) + + # Should return the default value since AMI key doesn't exist + assert result["Resources"]["MyInstance"]["Properties"]["ImageId"] == "ami-default" + + def test_validate_accepts_valid_find_in_map_with_default_value(self): + """ + Test that sam validate accepts templates with valid Fn::FindInMap with DefaultValue. + + **Validates: Requirements 5A.3** + + WHEN sam validate processes a template with valid Fn::FindInMap with + DefaultValue syntax, THE Validate_Command SHALL report the template as valid. + """ + from samcli.lib.cfn_language_extensions import process_template + + # This template should be valid and process without errors + template = { + "Mappings": { + "EnvConfig": { + "prod": {"LogLevel": "ERROR"}, + "dev": {"LogLevel": "DEBUG"}, + } + }, + "Parameters": { + "Environment": {"Type": "String", "Default": "dev"}, + }, + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Runtime": "python3.9", + "Handler": "index.handler", + "Code": {"ZipFile": "def handler(event, context): pass"}, + "Environment": { + "Variables": { + "LOG_LEVEL": { + "Fn::FindInMap": [ + "EnvConfig", + {"Ref": "Environment"}, + "LogLevel", + {"DefaultValue": "INFO"}, + ] + } + } + }, + }, + } + }, + } + + # Should process without raising an exception + result = process_template(template) + + # Verify the template was processed correctly + assert "MyFunction" in result["Resources"] + assert result["Resources"]["MyFunction"]["Properties"]["Environment"]["Variables"]["LOG_LEVEL"] == "DEBUG" + + def test_build_correctly_resolves_find_in_map_with_default_value(self): + """ + Test that sam build correctly resolves Fn::FindInMap with DefaultValue. + + **Validates: Requirements 5A.4** + + WHEN sam build processes a template using Fn::FindInMap with DefaultValue, + THE Build_Command SHALL correctly resolve the map lookup. + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "RuntimeConfig": { + "python": {"Runtime": "python3.9", "Handler": "app.handler"}, + "nodejs": {"Runtime": "nodejs18.x", "Handler": "index.handler"}, + } + }, + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Runtime": { + "Fn::FindInMap": [ + "RuntimeConfig", + "python", + "Runtime", + {"DefaultValue": "python3.8"}, + ] + }, + "Handler": { + "Fn::FindInMap": [ + "RuntimeConfig", + "python", + "Handler", + {"DefaultValue": "handler.main"}, + ] + }, + }, + } + }, + } + + result = process_template(template) + + # Verify the map lookups were resolved correctly + assert result["Resources"]["MyFunction"]["Properties"]["Runtime"] == "python3.9" + assert result["Resources"]["MyFunction"]["Properties"]["Handler"] == "app.handler" + + def test_default_value_with_complex_types(self): + """ + Test that Fn::FindInMap DefaultValue works with complex types (dict, list). + + **Validates: Requirements 5A.2** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "Config": { + "existing": {"Tags": {"Environment": "prod"}}, + } + }, + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "Tags": { + "Fn::FindInMap": [ + "Config", + "nonexistent", + "Tags", + {"DefaultValue": {"Environment": "default", "Project": "test"}}, + ] + } + }, + } + }, + } + + result = process_template(template) + + # Should return the complex default value + assert result["Resources"]["MyBucket"]["Properties"]["Tags"] == { + "Environment": "default", + "Project": "test", + } + + +# ============================================================================= +# Unit Tests for Fn::ForEach with Fn::FindInMap Integration +# ============================================================================= + + +class TestFnForEachWithFnFindInMapIntegration: + """ + Unit tests for Fn::ForEach integration with Fn::FindInMap. + + These tests validate that Fn::FindInMap works correctly within Fn::ForEach + loops, including scenarios where the loop variable is used in the map lookup. + + **Validates: Requirements 5A.1, 5A.2, 5A.3, 5A.4** + """ + + def test_foreach_with_find_in_map_using_loop_variable(self): + """ + Test Fn::ForEach with Fn::FindInMap using the loop variable as a key. + + This tests the integration where Fn::ForEach generates resources and + Fn::FindInMap uses the loop variable to look up configuration. + + **Validates: Requirements 5A.1, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "FunctionConfig": { + "Alpha": {"Runtime": "python3.9", "Memory": "128"}, + "Beta": {"Runtime": "nodejs18.x", "Memory": "256"}, + "Gamma": {"Runtime": "python3.11", "Memory": "512"}, + } + }, + "Resources": { + "Fn::ForEach::Functions": [ + "FunctionName", + ["Alpha", "Beta", "Gamma"], + { + "${FunctionName}Function": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${FunctionName}-handler"}, + "Runtime": {"Fn::FindInMap": ["FunctionConfig", "${FunctionName}", "Runtime"]}, + "MemorySize": {"Fn::FindInMap": ["FunctionConfig", "${FunctionName}", "Memory"]}, + "Handler": "index.handler", + "Code": {"ZipFile": "def handler(event, context): pass"}, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "AlphaFunction" in result["Resources"] + assert "BetaFunction" in result["Resources"] + assert "GammaFunction" in result["Resources"] + assert "Fn::ForEach::Functions" not in result["Resources"] + + # Verify Fn::FindInMap resolved correctly for each function + assert result["Resources"]["AlphaFunction"]["Properties"]["Runtime"] == "python3.9" + assert result["Resources"]["AlphaFunction"]["Properties"]["MemorySize"] == "128" + + assert result["Resources"]["BetaFunction"]["Properties"]["Runtime"] == "nodejs18.x" + assert result["Resources"]["BetaFunction"]["Properties"]["MemorySize"] == "256" + + assert result["Resources"]["GammaFunction"]["Properties"]["Runtime"] == "python3.11" + assert result["Resources"]["GammaFunction"]["Properties"]["MemorySize"] == "512" + + def test_foreach_with_find_in_map_default_value(self): + """ + Test Fn::ForEach with Fn::FindInMap using DefaultValue for missing keys. + + This tests the scenario where some loop values don't have corresponding + entries in the mappings, and DefaultValue is used as a fallback. + + **Validates: Requirements 5A.2, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "FunctionConfig": { + # Only Alpha and Beta have configurations + "Alpha": {"Timeout": "30"}, + "Beta": {"Timeout": "60"}, + # Gamma is missing - will use DefaultValue + } + }, + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta", "Gamma"], + { + "${Name}Function": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${Name}-function"}, + "Timeout": { + "Fn::FindInMap": [ + "FunctionConfig", + "${Name}", + "Timeout", + {"DefaultValue": "120"}, # Default for missing entries + ] + }, + "Runtime": "python3.9", + "Handler": "index.handler", + "Code": {"ZipFile": "def handler(event, context): pass"}, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "AlphaFunction" in result["Resources"] + assert "BetaFunction" in result["Resources"] + assert "GammaFunction" in result["Resources"] + + # Alpha and Beta should use mapped values + assert result["Resources"]["AlphaFunction"]["Properties"]["Timeout"] == "30" + assert result["Resources"]["BetaFunction"]["Properties"]["Timeout"] == "60" + + # Gamma should use the DefaultValue since it's not in the mappings + assert result["Resources"]["GammaFunction"]["Properties"]["Timeout"] == "120" + + def test_foreach_with_nested_find_in_map(self): + """ + Test Fn::ForEach with nested Fn::FindInMap lookups. + + This tests a more complex scenario where Fn::FindInMap is used + multiple times within a ForEach loop for different properties. + + **Validates: Requirements 5A.1, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "ServiceConfig": { + "Users": {"Port": "8080", "Protocol": "HTTP"}, + "Orders": {"Port": "8081", "Protocol": "HTTPS"}, + }, + "EnvironmentConfig": { + "Users": {"LogLevel": "INFO"}, + "Orders": {"LogLevel": "DEBUG"}, + }, + }, + "Resources": { + "Fn::ForEach::Services": [ + "ServiceName", + ["Users", "Orders"], + { + "${ServiceName}Service": { + "Type": "AWS::ECS::Service", + "Properties": { + "ServiceName": {"Fn::Sub": "${ServiceName}-service"}, + "DesiredCount": 1, + "TaskDefinition": { + "Fn::Sub": "arn:aws:ecs:us-east-1:123456789012:task-definition/${ServiceName}" + }, + }, + "Metadata": { + "Port": {"Fn::FindInMap": ["ServiceConfig", "${ServiceName}", "Port"]}, + "Protocol": {"Fn::FindInMap": ["ServiceConfig", "${ServiceName}", "Protocol"]}, + "LogLevel": {"Fn::FindInMap": ["EnvironmentConfig", "${ServiceName}", "LogLevel"]}, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "UsersService" in result["Resources"] + assert "OrdersService" in result["Resources"] + + # Verify multiple Fn::FindInMap lookups resolved correctly + assert result["Resources"]["UsersService"]["Metadata"]["Port"] == "8080" + assert result["Resources"]["UsersService"]["Metadata"]["Protocol"] == "HTTP" + assert result["Resources"]["UsersService"]["Metadata"]["LogLevel"] == "INFO" + + assert result["Resources"]["OrdersService"]["Metadata"]["Port"] == "8081" + assert result["Resources"]["OrdersService"]["Metadata"]["Protocol"] == "HTTPS" + assert result["Resources"]["OrdersService"]["Metadata"]["LogLevel"] == "DEBUG" + + def test_foreach_with_find_in_map_and_fn_sub(self): + """ + Test Fn::ForEach with Fn::FindInMap combined with Fn::Sub. + + This tests the integration where Fn::FindInMap result is used + within Fn::Sub for string interpolation. + + **Validates: Requirements 5A.1, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "DomainConfig": { + "Api": {"Subdomain": "api"}, + "Web": {"Subdomain": "www"}, + } + }, + "Resources": { + "Fn::ForEach::Endpoints": [ + "EndpointName", + ["Api", "Web"], + { + "${EndpointName}Endpoint": { + "Type": "AWS::Route53::RecordSet", + "Properties": { + "Name": { + "Fn::Sub": [ + "${Subdomain}.example.com", + { + "Subdomain": { + "Fn::FindInMap": ["DomainConfig", "${EndpointName}", "Subdomain"] + } + }, + ] + }, + "Type": "A", + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "ApiEndpoint" in result["Resources"] + assert "WebEndpoint" in result["Resources"] + + # Verify Fn::Sub with Fn::FindInMap resolved correctly + assert result["Resources"]["ApiEndpoint"]["Properties"]["Name"] == "api.example.com" + assert result["Resources"]["WebEndpoint"]["Properties"]["Name"] == "www.example.com" + + def test_foreach_with_find_in_map_in_conditions(self): + """ + Test Fn::ForEach with Fn::FindInMap used in conditional logic. + + This tests the scenario where Fn::FindInMap is used to look up + values that affect resource configuration within a ForEach loop. + + **Validates: Requirements 5A.1, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "QueueConfig": { + "HighPriority": {"VisibilityTimeout": "300", "DelaySeconds": "0"}, + "LowPriority": {"VisibilityTimeout": "60", "DelaySeconds": "30"}, + } + }, + "Resources": { + "Fn::ForEach::Queues": [ + "Priority", + ["HighPriority", "LowPriority"], + { + "${Priority}Queue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": {"Fn::Sub": "${Priority}-queue"}, + "VisibilityTimeout": { + "Fn::FindInMap": ["QueueConfig", "${Priority}", "VisibilityTimeout"] + }, + "DelaySeconds": {"Fn::FindInMap": ["QueueConfig", "${Priority}", "DelaySeconds"]}, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "HighPriorityQueue" in result["Resources"] + assert "LowPriorityQueue" in result["Resources"] + + # Verify Fn::FindInMap resolved correctly for each queue + assert result["Resources"]["HighPriorityQueue"]["Properties"]["VisibilityTimeout"] == "300" + assert result["Resources"]["HighPriorityQueue"]["Properties"]["DelaySeconds"] == "0" + + assert result["Resources"]["LowPriorityQueue"]["Properties"]["VisibilityTimeout"] == "60" + assert result["Resources"]["LowPriorityQueue"]["Properties"]["DelaySeconds"] == "30" + + def test_foreach_with_find_in_map_partial_defaults(self): + """ + Test Fn::ForEach with Fn::FindInMap where some properties use defaults. + + This tests a mixed scenario where some properties exist in mappings + and others fall back to DefaultValue. + + **Validates: Requirements 5A.1, 5A.2, 5A.3, 5A.4** + """ + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Mappings": { + "BucketConfig": { + "Logs": {"Versioning": "Enabled"}, + "Data": {"Versioning": "Suspended", "Encryption": "AES256"}, + # Assets has no configuration - will use all defaults + } + }, + "Resources": { + "Fn::ForEach::Buckets": [ + "BucketName", + ["Logs", "Data", "Assets"], + { + "${BucketName}Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": {"Fn::Sub": "${BucketName}-bucket"}, + "VersioningConfiguration": { + "Status": { + "Fn::FindInMap": [ + "BucketConfig", + "${BucketName}", + "Versioning", + {"DefaultValue": "Suspended"}, + ] + } + }, + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": { + "Fn::FindInMap": [ + "BucketConfig", + "${BucketName}", + "Encryption", + {"DefaultValue": "aws:kms"}, + ] + } + } + } + ] + }, + }, + } + }, + ] + }, + } + + result = process_template(template) + + # Verify ForEach was expanded + assert "LogsBucket" in result["Resources"] + assert "DataBucket" in result["Resources"] + assert "AssetsBucket" in result["Resources"] + + # Logs: Versioning from map, Encryption from default + assert result["Resources"]["LogsBucket"]["Properties"]["VersioningConfiguration"]["Status"] == "Enabled" + encryption_config = result["Resources"]["LogsBucket"]["Properties"]["BucketEncryption"] + assert ( + encryption_config["ServerSideEncryptionConfiguration"][0]["ServerSideEncryptionByDefault"]["SSEAlgorithm"] + == "aws:kms" + ) + + # Data: Both from map + assert result["Resources"]["DataBucket"]["Properties"]["VersioningConfiguration"]["Status"] == "Suspended" + encryption_config = result["Resources"]["DataBucket"]["Properties"]["BucketEncryption"] + assert ( + encryption_config["ServerSideEncryptionConfiguration"][0]["ServerSideEncryptionByDefault"]["SSEAlgorithm"] + == "AES256" + ) + + # Assets: Both from defaults (not in mappings) + assert result["Resources"]["AssetsBucket"]["Properties"]["VersioningConfiguration"]["Status"] == "Suspended" + encryption_config = result["Resources"]["AssetsBucket"]["Properties"]["BucketEncryption"] + assert ( + encryption_config["ServerSideEncryptionConfiguration"][0]["ServerSideEncryptionByDefault"]["SSEAlgorithm"] + == "aws:kms" + ) + + +class TestFnFindInMapNullValues: + """Tests for FnFindInMapResolver handling of null values in mappings.""" + + @pytest.fixture + def mappings_with_null(self) -> Dict[str, Any]: + return { + "RegionMap": { + "us-east-1": {"AMI": None, "InstanceType": "t2.micro"}, + "us-west-2": {"AMI": "ami-87654321"}, + } + } + + @pytest.fixture + def context(self, mappings_with_null: Dict[str, Any]) -> TemplateProcessingContext: + ctx = TemplateProcessingContext( + fragment={"Resources": {"MyInstance": {"Type": "AWS::EC2::Instance"}}, "Mappings": mappings_with_null} + ) + ctx.parsed_template = ParsedTemplate(resources={}, mappings=mappings_with_null) + return ctx + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnFindInMapResolver: + return FnFindInMapResolver(context, None) + + def test_null_value_without_default_raises_exception(self, resolver: FnFindInMapResolver): + """Test that null value in mapping without DefaultValue raises exception.""" + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + assert "us-east-1" in str(exc_info.value) or "AMI" in str(exc_info.value) + + def test_null_value_with_default_returns_default(self, resolver: FnFindInMapResolver): + """Test that null value in mapping with DefaultValue returns the default.""" + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI", {"DefaultValue": "ami-default"}]} + result = resolver.resolve(value) + assert result == "ami-default" + + +class TestFnFindInMapFallbackToFragment: + """Tests for FnFindInMapResolver fallback to fragment mappings.""" + + def test_fallback_to_fragment_when_no_parsed_template(self): + """Test that resolver falls back to fragment mappings when parsed_template is None.""" + mappings = {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}} + ctx = TemplateProcessingContext(fragment={"Resources": {}, "Mappings": mappings}) + ctx.parsed_template = None + resolver = FnFindInMapResolver(ctx, None) + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} + result = resolver.resolve(value) + assert result == "ami-12345678" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_if.py b/tests/unit/lib/cfn_language_extensions/test_fn_if.py new file mode 100644 index 00000000000..8ecacb89d14 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_if.py @@ -0,0 +1,942 @@ +""" +Unit tests for the FnIfResolver class. + +Tests cover: +- Basic Fn::If functionality with true/false conditions +- Condition evaluation from resolved_conditions cache +- Condition evaluation from template Conditions section +- AWS::NoValue handling +- Nested intrinsic function resolution +- Error handling for invalid inputs +- Integration with IntrinsicResolver orchestrator +- Property-based tests for universal correctness properties + +Requirements: + - 8.2: WHEN Fn::If references a condition, THEN THE Resolver SHALL evaluate + the condition and return the appropriate branch + - 8.3: WHEN Fn::If references a non-existent condition, THEN THE Resolver + SHALL raise an Invalid_Template_Exception + - 8.5: WHEN a resource has a Condition attribute, THEN THE Resolver SHALL + only process the resource if the condition evaluates to true +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_if import FnIfResolver, AWS_NO_VALUE +from samcli.lib.cfn_language_extensions.resolvers.condition_resolver import ConditionResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnIfResolverCanResolve: + """Tests for FnIfResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnIfResolver: + """Create a FnIfResolver for testing.""" + return FnIfResolver(context, None) + + def test_can_resolve_fn_if(self, resolver: FnIfResolver): + """Test that can_resolve returns True for Fn::If.""" + value = {"Fn::If": ["Condition", "true-value", "false-value"]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnIfResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Equals": ["a", "b"]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnIfResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnIfResolver): + """Test that FUNCTION_NAMES contains Fn::If.""" + assert FnIfResolver.FUNCTION_NAMES == ["Fn::If"] + + +class TestFnIfResolverBasicFunctionality: + """Tests for basic Fn::If functionality. + + Requirement 8.2: WHEN Fn::If references a condition, THEN THE Resolver + SHALL evaluate the condition and return the appropriate branch + """ + + @pytest.fixture + def context_with_resolved_conditions(self) -> TemplateProcessingContext: + """Create a context with pre-resolved conditions.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.resolved_conditions = { + "IsProduction": True, + "IsDevelopment": False, + "AlwaysTrue": True, + "AlwaysFalse": False, + } + return context + + @pytest.fixture + def resolver(self, context_with_resolved_conditions: TemplateProcessingContext) -> FnIfResolver: + """Create a FnIfResolver for testing.""" + return FnIfResolver(context_with_resolved_conditions, None) + + def test_fn_if_returns_true_branch_when_condition_true(self, resolver: FnIfResolver): + """Test Fn::If returns value_if_true when condition is true.""" + value = {"Fn::If": ["IsProduction", "prod-value", "dev-value"]} + result = resolver.resolve(value) + assert result == "prod-value" + + def test_fn_if_returns_false_branch_when_condition_false(self, resolver: FnIfResolver): + """Test Fn::If returns value_if_false when condition is false.""" + value = {"Fn::If": ["IsDevelopment", "dev-value", "other-value"]} + result = resolver.resolve(value) + assert result == "other-value" + + def test_fn_if_with_always_true_condition(self, resolver: FnIfResolver): + """Test Fn::If with a condition that is always true.""" + value = {"Fn::If": ["AlwaysTrue", "yes", "no"]} + result = resolver.resolve(value) + assert result == "yes" + + def test_fn_if_with_always_false_condition(self, resolver: FnIfResolver): + """Test Fn::If with a condition that is always false.""" + value = {"Fn::If": ["AlwaysFalse", "yes", "no"]} + result = resolver.resolve(value) + assert result == "no" + + def test_fn_if_with_dict_values(self, resolver: FnIfResolver): + """Test Fn::If with dictionary values in branches.""" + value = {"Fn::If": ["IsProduction", {"key": "prod"}, {"key": "dev"}]} + result = resolver.resolve(value) + assert result == {"key": "prod"} + + def test_fn_if_with_list_values(self, resolver: FnIfResolver): + """Test Fn::If with list values in branches.""" + value = {"Fn::If": ["IsProduction", ["a", "b"], ["c", "d"]]} + result = resolver.resolve(value) + assert result == ["a", "b"] + + def test_fn_if_with_integer_values(self, resolver: FnIfResolver): + """Test Fn::If with integer values in branches.""" + value = {"Fn::If": ["IsProduction", 100, 50]} + result = resolver.resolve(value) + assert result == 100 + + def test_fn_if_with_boolean_values(self, resolver: FnIfResolver): + """Test Fn::If with boolean values in branches.""" + value = {"Fn::If": ["IsProduction", True, False]} + result = resolver.resolve(value) + assert result is True + + +class TestFnIfResolverConditionEvaluation: + """Tests for condition evaluation from template Conditions section.""" + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + """Create a context with conditions defined in parsed template.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "IsProduction": {"Fn::Equals": ["prod", "prod"]}, + "IsDevelopment": {"Fn::Equals": ["dev", "prod"]}, + "BooleanTrue": True, + "BooleanFalse": False, + "StringTrue": "true", + "StringFalse": "false", + }, + ) + return context + + @pytest.fixture + def orchestrator(self, context_with_conditions: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnIfResolver and ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + return orchestrator + + def test_fn_if_evaluates_condition_from_template(self, orchestrator: IntrinsicResolver): + """Test Fn::If evaluates condition from template Conditions section.""" + value = {"Fn::If": ["IsProduction", "prod", "dev"]} + result = orchestrator.resolve_value(value) + assert result == "prod" + + def test_fn_if_evaluates_false_condition_from_template(self, orchestrator: IntrinsicResolver): + """Test Fn::If evaluates false condition from template.""" + value = {"Fn::If": ["IsDevelopment", "dev", "other"]} + result = orchestrator.resolve_value(value) + assert result == "other" + + def test_fn_if_with_boolean_true_condition(self, orchestrator: IntrinsicResolver): + """Test Fn::If with boolean true condition.""" + value = {"Fn::If": ["BooleanTrue", "yes", "no"]} + result = orchestrator.resolve_value(value) + assert result == "yes" + + def test_fn_if_with_boolean_false_condition(self, orchestrator: IntrinsicResolver): + """Test Fn::If with boolean false condition.""" + value = {"Fn::If": ["BooleanFalse", "yes", "no"]} + result = orchestrator.resolve_value(value) + assert result == "no" + + def test_fn_if_with_string_true_condition(self, orchestrator: IntrinsicResolver): + """Test Fn::If with string 'true' condition.""" + value = {"Fn::If": ["StringTrue", "yes", "no"]} + result = orchestrator.resolve_value(value) + assert result == "yes" + + def test_fn_if_with_string_false_condition(self, orchestrator: IntrinsicResolver): + """Test Fn::If with string 'false' condition.""" + value = {"Fn::If": ["StringFalse", "yes", "no"]} + result = orchestrator.resolve_value(value) + assert result == "no" + + def test_fn_if_caches_condition_result(self, context_with_conditions: TemplateProcessingContext): + """Test that condition results are cached after evaluation.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + + # First resolution + value = {"Fn::If": ["IsProduction", "prod", "dev"]} + orchestrator.resolve_value(value) + + # Check that result is cached + assert "IsProduction" in context_with_conditions.resolved_conditions + assert context_with_conditions.resolved_conditions["IsProduction"] is True + + +class TestFnIfResolverNonExistentCondition: + """Tests for non-existent condition handling. + + Requirement 8.3: WHEN Fn::If references a non-existent condition, THEN THE + Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + """Create a context with conditions defined.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "ExistingCondition": True, + }, + ) + return context + + @pytest.fixture + def orchestrator(self, context_with_conditions: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnIfResolver registered.""" + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + return orchestrator + + def test_fn_if_raises_for_non_existent_condition(self, orchestrator: IntrinsicResolver): + """Test Fn::If raises InvalidTemplateException for non-existent condition.""" + value = {"Fn::If": ["NonExistentCondition", "yes", "no"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Condition 'NonExistentCondition' not found" in str(exc_info.value) + + def test_fn_if_raises_when_no_parsed_template(self): + """Test Fn::If raises when parsed_template is None.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + # parsed_template is None by default + + resolver = FnIfResolver(context, None) + value = {"Fn::If": ["SomeCondition", "yes", "no"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Condition 'SomeCondition' not found" in str(exc_info.value) + + +class TestFnIfResolverAwsNoValue: + """Tests for AWS::NoValue handling. + + When Fn::If returns {"Ref": "AWS::NoValue"}, the property should be removed. + """ + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + """Create a context with conditions defined.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.resolved_conditions = { + "IsProduction": True, + "IsDevelopment": False, + } + return context + + @pytest.fixture + def resolver(self, context_with_conditions: TemplateProcessingContext) -> FnIfResolver: + """Create a FnIfResolver for testing.""" + return FnIfResolver(context_with_conditions, None) + + def test_fn_if_returns_none_for_no_value_true_branch(self, resolver: FnIfResolver): + """Test Fn::If returns None when true branch is AWS::NoValue.""" + value = {"Fn::If": ["IsProduction", {"Ref": "AWS::NoValue"}, "dev-value"]} + result = resolver.resolve(value) + assert result is None + + def test_fn_if_returns_none_for_no_value_false_branch(self, resolver: FnIfResolver): + """Test Fn::If returns None when false branch is AWS::NoValue.""" + value = {"Fn::If": ["IsDevelopment", "dev-value", {"Ref": "AWS::NoValue"}]} + result = resolver.resolve(value) + assert result is None + + def test_fn_if_returns_value_when_no_value_not_selected(self, resolver: FnIfResolver): + """Test Fn::If returns value when AWS::NoValue is not selected.""" + value = {"Fn::If": ["IsProduction", "prod-value", {"Ref": "AWS::NoValue"}]} + result = resolver.resolve(value) + assert result == "prod-value" + + def test_is_no_value_ref_with_valid_no_value(self, resolver: FnIfResolver): + """Test _is_no_value_ref returns True for valid AWS::NoValue ref.""" + assert resolver._is_no_value_ref({"Ref": "AWS::NoValue"}) is True + + def test_is_no_value_ref_with_other_ref(self, resolver: FnIfResolver): + """Test _is_no_value_ref returns False for other Ref values.""" + assert resolver._is_no_value_ref({"Ref": "SomeParameter"}) is False + + def test_is_no_value_ref_with_non_dict(self, resolver: FnIfResolver): + """Test _is_no_value_ref returns False for non-dict values.""" + assert resolver._is_no_value_ref("string") is False + assert resolver._is_no_value_ref(123) is False + assert resolver._is_no_value_ref(None) is False + + def test_is_no_value_ref_with_multi_key_dict(self, resolver: FnIfResolver): + """Test _is_no_value_ref returns False for dict with multiple keys.""" + assert resolver._is_no_value_ref({"Ref": "AWS::NoValue", "extra": "key"}) is False + + +class TestFnIfResolverInvalidLayout: + """Tests for invalid layout handling.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.resolved_conditions = {"SomeCondition": True} + return context + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnIfResolver: + """Create a FnIfResolver for testing.""" + return FnIfResolver(context, None) + + def test_fn_if_invalid_layout_not_list(self, resolver: FnIfResolver): + """Test Fn::If with non-list raises InvalidTemplateException.""" + value = {"Fn::If": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_empty_list(self, resolver: FnIfResolver): + """Test Fn::If with empty list raises InvalidTemplateException.""" + value: Dict[str, Any] = {"Fn::If": []} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_one_element(self, resolver: FnIfResolver): + """Test Fn::If with one element raises InvalidTemplateException.""" + value = {"Fn::If": ["Condition"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_two_elements(self, resolver: FnIfResolver): + """Test Fn::If with two elements raises InvalidTemplateException.""" + value = {"Fn::If": ["Condition", "value"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_four_elements(self, resolver: FnIfResolver): + """Test Fn::If with four elements raises InvalidTemplateException.""" + value = {"Fn::If": ["Condition", "true", "false", "extra"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_non_string_condition(self, resolver: FnIfResolver): + """Test Fn::If with non-string condition name raises InvalidTemplateException.""" + value = {"Fn::If": [123, "true", "false"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_list_condition(self, resolver: FnIfResolver): + """Test Fn::If with list as condition name raises InvalidTemplateException.""" + value = {"Fn::If": [["Condition"], "true", "false"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + def test_fn_if_invalid_layout_dict_condition(self, resolver: FnIfResolver): + """Test Fn::If with dict as condition name raises InvalidTemplateException.""" + value = {"Fn::If": [{"key": "value"}, "true", "false"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::If layout is incorrect" in str(exc_info.value) + + +class TestFnIfResolverNestedIntrinsics: + """Tests for nested intrinsic function resolution.""" + + @pytest.fixture + def context_with_conditions(self) -> TemplateProcessingContext: + """Create a context with conditions defined.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Environment": "prod"}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + parameters={"Environment": {"Type": "String"}}, + conditions={ + "IsProduction": True, + "IsDevelopment": False, + }, + ) + context.resolved_conditions = { + "IsProduction": True, + "IsDevelopment": False, + } + return context + + @pytest.fixture + def orchestrator(self, context_with_conditions: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with multiple resolvers registered.""" + from samcli.lib.cfn_language_extensions.resolvers.fn_join import FnJoinResolver + from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver + + orchestrator = IntrinsicResolver(context_with_conditions) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + orchestrator.register_resolver(FnJoinResolver) + orchestrator.register_resolver(FnRefResolver) + return orchestrator + + def test_fn_if_resolves_nested_fn_join_in_true_branch(self, orchestrator: IntrinsicResolver): + """Test Fn::If resolves nested Fn::Join in true branch.""" + value = {"Fn::If": ["IsProduction", {"Fn::Join": ["-", ["prod", "value"]]}, "dev-value"]} + result = orchestrator.resolve_value(value) + assert result == "prod-value" + + def test_fn_if_resolves_nested_fn_join_in_false_branch(self, orchestrator: IntrinsicResolver): + """Test Fn::If resolves nested Fn::Join in false branch.""" + value = {"Fn::If": ["IsDevelopment", "dev-value", {"Fn::Join": ["-", ["other", "value"]]}]} + result = orchestrator.resolve_value(value) + assert result == "other-value" + + def test_fn_if_resolves_nested_ref_in_true_branch(self, orchestrator: IntrinsicResolver): + """Test Fn::If resolves nested Ref in true branch.""" + value = {"Fn::If": ["IsProduction", {"Ref": "Environment"}, "dev"]} + result = orchestrator.resolve_value(value) + assert result == "prod" + + def test_fn_if_with_nested_dict_containing_intrinsics(self, orchestrator: IntrinsicResolver): + """Test Fn::If resolves intrinsics in nested dict values.""" + value = { + "Fn::If": [ + "IsProduction", + {"Name": {"Fn::Join": ["-", ["app", "prod"]]}, "Env": {"Ref": "Environment"}}, + {"Name": "app-dev", "Env": "dev"}, + ] + } + result = orchestrator.resolve_value(value) + assert result == {"Name": "app-prod", "Env": "prod"} + + +class TestFnIfResolverWithOrchestrator: + """Tests for FnIfResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a template processing context.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + ) + context.parsed_template = ParsedTemplate( + resources={}, + conditions={ + "IsProduction": {"Fn::Equals": ["prod", "prod"]}, + "IsDevelopment": {"Fn::Equals": ["dev", "prod"]}, + }, + ) + return context + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnIfResolver and ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::If through the orchestrator.""" + value = {"Fn::If": ["IsProduction", "prod", "dev"]} + result = orchestrator.resolve_value(value) + assert result == "prod" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::If in a nested structure.""" + value = {"Properties": {"Environment": {"Fn::If": ["IsProduction", "production", "development"]}}} + result = orchestrator.resolve_value(value) + assert result == {"Properties": {"Environment": "production"}} + + def test_resolve_multiple_fn_if_in_structure(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::If in a structure.""" + value = { + "Env": {"Fn::If": ["IsProduction", "prod", "dev"]}, + "Debug": {"Fn::If": ["IsDevelopment", True, False]}, + } + result = orchestrator.resolve_value(value) + assert result == {"Env": "prod", "Debug": False} + + def test_nested_fn_if(self, orchestrator: IntrinsicResolver): + """Test nested Fn::If expressions.""" + value = {"Fn::If": ["IsProduction", {"Fn::If": ["IsDevelopment", "nested-dev", "nested-prod"]}, "outer-dev"]} + result = orchestrator.resolve_value(value) + # IsProduction is True, so we evaluate the inner Fn::If + # IsDevelopment is False, so we get "nested-prod" + assert result == "nested-prod" + + +class TestFnIfResolverToBooleanConversion: + """Tests for _to_boolean conversion method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnIfResolver: + """Create a FnIfResolver for testing.""" + return FnIfResolver(context, None) + + def test_to_boolean_with_true(self, resolver: FnIfResolver): + """Test _to_boolean with boolean True.""" + assert resolver._to_boolean(True) is True + + def test_to_boolean_with_false(self, resolver: FnIfResolver): + """Test _to_boolean with boolean False.""" + assert resolver._to_boolean(False) is False + + def test_to_boolean_with_string_true(self, resolver: FnIfResolver): + """Test _to_boolean with string 'true'.""" + assert resolver._to_boolean("true") is True + + def test_to_boolean_with_string_false(self, resolver: FnIfResolver): + """Test _to_boolean with string 'false'.""" + assert resolver._to_boolean("false") is False + + def test_to_boolean_with_string_true_uppercase(self, resolver: FnIfResolver): + """Test _to_boolean with string 'TRUE'.""" + assert resolver._to_boolean("TRUE") is True + + def test_to_boolean_with_string_false_uppercase(self, resolver: FnIfResolver): + """Test _to_boolean with string 'FALSE'.""" + assert resolver._to_boolean("FALSE") is False + + def test_to_boolean_with_string_true_mixed_case(self, resolver: FnIfResolver): + """Test _to_boolean with string 'True'.""" + assert resolver._to_boolean("True") is True + + def test_to_boolean_with_string_false_mixed_case(self, resolver: FnIfResolver): + """Test _to_boolean with string 'False'.""" + assert resolver._to_boolean("False") is False + + def test_to_boolean_with_non_empty_string(self, resolver: FnIfResolver): + """Test _to_boolean with non-empty string (truthy).""" + assert resolver._to_boolean("some-string") is True + + def test_to_boolean_with_empty_string(self, resolver: FnIfResolver): + """Test _to_boolean with empty string (falsy).""" + assert resolver._to_boolean("") is False + + def test_to_boolean_with_non_zero_integer(self, resolver: FnIfResolver): + """Test _to_boolean with non-zero integer (truthy).""" + assert resolver._to_boolean(1) is True + assert resolver._to_boolean(42) is True + + def test_to_boolean_with_zero(self, resolver: FnIfResolver): + """Test _to_boolean with zero (falsy).""" + assert resolver._to_boolean(0) is False + + def test_to_boolean_with_non_empty_list(self, resolver: FnIfResolver): + """Test _to_boolean with non-empty list (truthy).""" + assert resolver._to_boolean([1, 2, 3]) is True + + def test_to_boolean_with_empty_list(self, resolver: FnIfResolver): + """Test _to_boolean with empty list (falsy).""" + assert resolver._to_boolean([]) is False + + +class TestFnIfResolverAwsNoValueConstant: + """Tests for AWS_NO_VALUE constant.""" + + def test_aws_no_value_constant(self): + """Test AWS_NO_VALUE constant value.""" + assert AWS_NO_VALUE == "AWS::NoValue" + + +# ============================================================================= +# Parametrized Tests for Fn::If Condition Evaluation +# ============================================================================= + + +class TestFnIfParametrizedTests: + """ + Parametrized tests for Fn::If condition evaluation. + + These tests validate that for any Fn::If referencing a condition that evaluates + to true, the resolver SHALL return the second argument (value_if_true); for false, + it SHALL return the third argument (value_if_false). + + **Validates: Requirements 8.2, 8.5** + """ + + @staticmethod + def _create_context_with_condition(condition_name: str, condition_value: bool) -> TemplateProcessingContext: + """Create a template processing context with a pre-resolved condition.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + context.resolved_conditions = {condition_name: condition_value} + context.parsed_template = ParsedTemplate(resources={}, conditions={condition_name: condition_value}) + return context + + @staticmethod + def _create_orchestrator(context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnIfResolver and ConditionResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(ConditionResolver) + orchestrator.register_resolver(FnIfResolver) + return orchestrator + + @pytest.mark.parametrize( + "condition_name, value_if_true, value_if_false", + [ + ("IsProduction", "prod-value", "dev-value"), + ("EnableFeature", {"key": "enabled"}, {"key": "disabled"}), + ("UseHighCapacity", [1, 2, 3], []), + ], + ) + def test_fn_if_returns_true_branch_when_condition_is_true( + self, + condition_name: str, + value_if_true: Any, + value_if_false: Any, + ): + """ + For any Fn::If referencing a condition that evaluates to true, the resolver + SHALL return the second argument (value_if_true). + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, True) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::If": [condition_name, value_if_true, value_if_false]} + result = orchestrator.resolve_value(value) + + assert result == value_if_true + + @pytest.mark.parametrize( + "condition_name, value_if_true, value_if_false", + [ + ("IsProduction", "prod-value", "dev-value"), + ("EnableFeature", {"key": "enabled"}, {"key": "disabled"}), + ("UseHighCapacity", [1, 2, 3], []), + ], + ) + def test_fn_if_returns_false_branch_when_condition_is_false( + self, + condition_name: str, + value_if_true: Any, + value_if_false: Any, + ): + """ + For any Fn::If referencing a condition that evaluates to false, the resolver + SHALL return the third argument (value_if_false). + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, False) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::If": [condition_name, value_if_true, value_if_false]} + result = orchestrator.resolve_value(value) + + assert result == value_if_false + + @pytest.mark.parametrize( + "condition_name, condition_value, value_if_true, value_if_false", + [ + ("IsProduction", True, "prod", "dev"), + ("IsProduction", False, "prod", "dev"), + ("EnableDebug", True, 100, 0), + ], + ) + def test_fn_if_returns_correct_branch_for_any_condition_value( + self, + condition_name: str, + condition_value: bool, + value_if_true: Any, + value_if_false: Any, + ): + """ + For any condition value (true or false), Fn::If SHALL return the correct branch. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + value = {"Fn::If": [condition_name, value_if_true, value_if_false]} + result = orchestrator.resolve_value(value) + + expected = value_if_true if condition_value else value_if_false + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value, string_value", + [ + ("IsProduction", True, "hello"), + ("IsProduction", False, "world"), + ("EnableFeature", True, ""), + ], + ) + def test_fn_if_with_string_branch_values( + self, + condition_name: str, + condition_value: bool, + string_value: str, + ): + """ + For any Fn::If with string branch values, the resolver SHALL return the + correct string based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = f"true-{string_value}" + false_value = f"false-{string_value}" + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value, int_value", + [ + ("IsProduction", True, 42), + ("IsProduction", False, -100), + ("EnableFeature", True, 0), + ], + ) + def test_fn_if_with_integer_branch_values( + self, + condition_name: str, + condition_value: bool, + int_value: int, + ): + """ + For any Fn::If with integer branch values, the resolver SHALL return the + correct integer based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = int_value + false_value = int_value * 2 + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value, list_items", + [ + ("IsProduction", True, ["a", "b", "c"]), + ("IsProduction", False, ["x"]), + ("EnableFeature", True, []), + ], + ) + def test_fn_if_with_list_branch_values( + self, + condition_name: str, + condition_value: bool, + list_items: List[str], + ): + """ + For any Fn::If with list branch values, the resolver SHALL return the + correct list based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = list_items + false_value = list_items[::-1] + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value, dict_value", + [ + ("IsProduction", True, {"region": "us-east-1"}), + ("IsProduction", False, {"tier": "standard", "count": "5"}), + ("EnableFeature", True, {}), + ], + ) + def test_fn_if_with_dict_branch_values( + self, + condition_name: str, + condition_value: bool, + dict_value: Dict[str, str], + ): + """ + For any Fn::If with dictionary branch values, the resolver SHALL return the + correct dictionary based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = {"env": "prod", **dict_value} + false_value = {"env": "dev", **dict_value} + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value", + [ + ("IsProduction", True), + ("IsProduction", False), + ("EnableFeature", True), + ], + ) + def test_fn_if_with_boolean_branch_values( + self, + condition_name: str, + condition_value: bool, + ): + """ + For any Fn::If with boolean branch values, the resolver SHALL return the + correct boolean based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = True + false_value = False + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected + + @pytest.mark.parametrize( + "condition_name, condition_value", + [ + ("IsProduction", True), + ("IsProduction", False), + ("EnableFeature", False), + ], + ) + def test_fn_if_with_none_branch_values( + self, + condition_name: str, + condition_value: bool, + ): + """ + For any Fn::If with None branch values, the resolver SHALL return the + correct None or non-None value based on the condition value. + + **Validates: Requirements 8.2, 8.5** + """ + context = self._create_context_with_condition(condition_name, condition_value) + orchestrator = self._create_orchestrator(context) + + true_value = "has-value" + false_value = None + + value = {"Fn::If": [condition_name, true_value, false_value]} + result = orchestrator.resolve_value(value) + + expected = true_value if condition_value else false_value + assert result == expected diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_join.py b/tests/unit/lib/cfn_language_extensions/test_fn_join.py new file mode 100644 index 00000000000..08a1121d2ee --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_join.py @@ -0,0 +1,480 @@ +""" +Unit tests for the FnJoinResolver class. + +Tests cover: +- Basic Fn::Join functionality with literal lists +- Nested intrinsic function resolution +- Error handling for invalid inputs +- Integration with IntrinsicResolver orchestrator + +Requirements: + - 10.3: WHEN Fn::Join is applied to a list with a delimiter, THEN THE + Resolver SHALL return a string with all list elements joined by + the delimiter +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_join import FnJoinResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnJoinResolverCanResolve: + """Tests for FnJoinResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnJoinResolver: + """Create a FnJoinResolver for testing.""" + return FnJoinResolver(context, None) + + def test_can_resolve_fn_join(self, resolver: FnJoinResolver): + """Test that can_resolve returns True for Fn::Join.""" + value = {"Fn::Join": [",", ["a", "b", "c"]]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnJoinResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Split": [",", "a,b,c"]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnJoinResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnJoinResolver): + """Test that FUNCTION_NAMES contains Fn::Join.""" + assert FnJoinResolver.FUNCTION_NAMES == ["Fn::Join"] + + +class TestFnJoinResolverBasicFunctionality: + """Tests for basic Fn::Join functionality. + + Requirement 10.3: WHEN Fn::Join is applied to a list with a delimiter, + THEN THE Resolver SHALL return a string with all list elements joined + by the delimiter + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnJoinResolver: + """Create a FnJoinResolver for testing.""" + return FnJoinResolver(context, None) + + def test_join_with_comma_delimiter(self, resolver: FnJoinResolver): + """Test Fn::Join with comma delimiter. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [",", ["a", "b", "c"]]} + assert resolver.resolve(value) == "a,b,c" + + def test_join_with_hyphen_delimiter(self, resolver: FnJoinResolver): + """Test Fn::Join with hyphen delimiter. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": ["-", ["2023", "01", "15"]]} + assert resolver.resolve(value) == "2023-01-15" + + def test_join_with_empty_delimiter(self, resolver: FnJoinResolver): + """Test Fn::Join with empty delimiter. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": ["", ["Hello", "World"]]} + assert resolver.resolve(value) == "HelloWorld" + + def test_join_with_space_delimiter(self, resolver: FnJoinResolver): + """Test Fn::Join with space delimiter. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [" ", ["Hello", "World"]]} + assert resolver.resolve(value) == "Hello World" + + def test_join_with_multi_char_delimiter(self, resolver: FnJoinResolver): + """Test Fn::Join with multi-character delimiter. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [" :: ", ["a", "b", "c"]]} + assert resolver.resolve(value) == "a :: b :: c" + + def test_join_empty_list(self, resolver: FnJoinResolver): + """Test Fn::Join with empty list returns empty string. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [",", []]} + assert resolver.resolve(value) == "" + + def test_join_single_element_list(self, resolver: FnJoinResolver): + """Test Fn::Join with single element list. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [",", ["single"]]} + assert resolver.resolve(value) == "single" + + def test_join_with_numbers(self, resolver: FnJoinResolver): + """Test Fn::Join with numeric elements. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": ["-", [1, 2, 3]]} + assert resolver.resolve(value) == "1-2-3" + + def test_join_with_mixed_types(self, resolver: FnJoinResolver): + """Test Fn::Join with mixed type elements. + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [",", ["string", 42, 3.14, True, False]]} + assert resolver.resolve(value) == "string,42,3.14,true,false" + + def test_join_with_none_element(self, resolver: FnJoinResolver): + """Test Fn::Join with None element (converts to empty string). + + Requirement 10.3: Return string with elements joined by delimiter + """ + value = {"Fn::Join": [",", ["a", None, "b"]]} + assert resolver.resolve(value) == "a,,b" + + +class TestFnJoinResolverErrorHandling: + """Tests for Fn::Join error handling.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnJoinResolver: + """Create a FnJoinResolver for testing.""" + return FnJoinResolver(context, None) + + def test_non_list_args_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with non-list args raises InvalidTemplateException.""" + value = {"Fn::Join": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_single_element_args_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with single element args raises InvalidTemplateException.""" + value = {"Fn::Join": [","]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_three_element_args_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with three element args raises InvalidTemplateException.""" + value = {"Fn::Join": [",", ["a", "b"], "extra"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_non_string_delimiter_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with non-string delimiter raises InvalidTemplateException.""" + value = {"Fn::Join": [123, ["a", "b"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_non_list_second_arg_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with non-list second arg raises InvalidTemplateException.""" + value = {"Fn::Join": [",", "not-a-list"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_none_args_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with None args raises InvalidTemplateException.""" + value = {"Fn::Join": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + def test_dict_args_raises_exception(self, resolver: FnJoinResolver): + """Test Fn::Join with dict args raises InvalidTemplateException.""" + value = {"Fn::Join": {"delimiter": ",", "list": ["a", "b"]}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Join layout is incorrect" in str(exc_info.value) + + +class MockSplitResolver(IntrinsicFunctionResolver): + """A mock resolver that implements Fn::Split for testing nested intrinsics.""" + + FUNCTION_NAMES = ["Fn::Split"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Split a string by delimiter.""" + args = self.get_function_args(value) + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Split layout is incorrect") + + delimiter = args[0] + source_string = args[1] + + # Resolve nested intrinsics + if self.parent is not None: + source_string = self.parent.resolve_value(source_string) + + if not isinstance(source_string, str): + raise InvalidTemplateException("Fn::Split layout is incorrect") + + return source_string.split(delimiter) + + +class TestFnJoinResolverNestedIntrinsics: + """Tests for Fn::Join with nested intrinsic functions.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Delimiter": "-", + "Items": ["a", "b", "c"], + }, + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnJoinResolver and FnRefResolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnJoinResolver) + orchestrator.register_resolver(FnRefResolver) + orchestrator.register_resolver(MockSplitResolver) + return orchestrator + + def test_nested_ref_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Join with Ref in list items.""" + # Delimiter parameter is "-", so we join ["-", "value"] with "-" + # Result: "-" + "-" + "value" = "--value" + value = {"Fn::Join": ["-", [{"Ref": "Delimiter"}, "value"]]} + result = orchestrator.resolve_value(value) + assert result == "--value" + + def test_nested_ref_for_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Join with Ref resolving to list.""" + value = {"Fn::Join": [",", {"Ref": "Items"}]} + result = orchestrator.resolve_value(value) + assert result == "a,b,c" + + def test_nested_split_in_join(self, orchestrator: IntrinsicResolver): + """Test Fn::Join with nested Fn::Split.""" + value = {"Fn::Join": ["-", {"Fn::Split": [",", "a,b,c"]}]} + result = orchestrator.resolve_value(value) + assert result == "a-b-c" + + +class TestFnJoinResolverWithOrchestrator: + """Tests for FnJoinResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnJoinResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnJoinResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Join through the orchestrator.""" + value = {"Fn::Join": [",", ["a", "b", "c"]]} + result = orchestrator.resolve_value(value) + assert result == "a,b,c" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Join in a nested template structure.""" + value = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::Join": ["-", ["my", "bucket", "name"]]}}, + } + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "my-bucket-name" + + def test_resolve_multiple_fn_join(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Join in same structure.""" + value = { + "first": {"Fn::Join": [",", ["a", "b"]]}, + "second": {"Fn::Join": ["-", ["x", "y", "z"]]}, + "third": {"Fn::Join": ["", ["Hello", "World"]]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "first": "a,b", + "second": "x-y-z", + "third": "HelloWorld", + } + + def test_fn_join_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Join inside a list.""" + value = [ + {"Fn::Join": [",", ["a"]]}, + {"Fn::Join": [",", ["a", "b"]]}, + {"Fn::Join": [",", ["a", "b", "c"]]}, + ] + result = orchestrator.resolve_value(value) + + assert result == ["a", "a,b", "a,b,c"] + + +class TestFnJoinResolverPartialMode: + """Tests for FnJoinResolver in partial resolution mode.""" + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnJoinResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnJoinResolver) + return orchestrator + + def test_fn_join_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Join is resolved even in partial mode.""" + value = {"Fn::Join": [",", ["a", "b", "c"]]} + result = orchestrator.resolve_value(value) + + assert result == "a,b,c" + + def test_fn_join_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::Join alongside preserved intrinsics in partial mode.""" + value = { + "joined": {"Fn::Join": ["-", ["a", "b"]]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "joined": "a-b", + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +class TestFnJoinResolverRealWorldExamples: + """Tests for Fn::Join with real-world CloudFormation patterns.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnJoinResolver: + """Create a FnJoinResolver for testing.""" + return FnJoinResolver(context, None) + + def test_join_arn_components(self, resolver: FnJoinResolver): + """Test joining ARN components.""" + value = {"Fn::Join": [":", ["arn", "aws", "s3", "", "", "my-bucket"]]} + assert resolver.resolve(value) == "arn:aws:s3:::my-bucket" + + def test_join_path_components(self, resolver: FnJoinResolver): + """Test joining path components.""" + value = {"Fn::Join": ["/", ["", "var", "log", "app"]]} + assert resolver.resolve(value) == "/var/log/app" + + def test_join_cidr_blocks(self, resolver: FnJoinResolver): + """Test joining CIDR block components.""" + value = {"Fn::Join": [".", ["10", "0", "0", "0/16"]]} + assert resolver.resolve(value) == "10.0.0.0/16" + + def test_join_tags(self, resolver: FnJoinResolver): + """Test joining tag values.""" + value = {"Fn::Join": ["-", ["prod", "us-east-1", "app"]]} + assert resolver.resolve(value) == "prod-us-east-1-app" + + +class TestFnJoinToStringEdgeCases: + """Tests for FnJoinResolver._to_string() method edge cases (lines 134-138).""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnJoinResolver: + return FnJoinResolver(context, None) + + def test_join_with_nested_list(self, resolver: FnJoinResolver): + """Test Fn::Join with nested list elements (converts to comma-separated string).""" + value = {"Fn::Join": ["-", [["a", "b"], "c"]]} + result = resolver.resolve(value) + assert result == "a,b-c" + + def test_join_with_dict_element(self, resolver: FnJoinResolver): + """Test Fn::Join with dict element (converts to string representation).""" + value = {"Fn::Join": ["-", ["prefix", {"key": "value"}, "suffix"]]} + result = resolver.resolve(value) + assert "prefix" in result + assert "suffix" in result + assert "key" in result + + def test_join_with_deeply_nested_list(self, resolver: FnJoinResolver): + """Test Fn::Join with deeply nested list elements.""" + value = {"Fn::Join": ["-", [["x", ["y", "z"]], "end"]]} + result = resolver.resolve(value) + assert "x" in result + assert "y" in result + assert "z" in result + assert "end" in result diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_length.py b/tests/unit/lib/cfn_language_extensions/test_fn_length.py new file mode 100644 index 00000000000..f7fc4257c07 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_length.py @@ -0,0 +1,708 @@ +""" +Unit tests for the FnLengthResolver class. + +Tests cover: +- Basic Fn::Length functionality with literal lists +- Nested intrinsic function resolution +- Error handling for non-list inputs +- Integration with IntrinsicResolver orchestrator +- Property-based tests for universal correctness properties + +Requirements: + - 3.1: WHEN Fn::Length is applied to a list, THEN THE Resolver SHALL return + the number of elements in the list + - 3.2: WHEN Fn::Length is applied to a nested intrinsic function that resolves + to a list, THEN THE Resolver SHALL first resolve the inner function + and then return the length + - 3.3: WHEN Fn::Length is applied to a non-list value, THEN THE Resolver SHALL + raise an Invalid_Template_Exception indicating incorrect layout + - 3.4: WHEN Fn::Length references a parameter that resolves to a CommaDelimitedList, + THEN THE Resolver SHALL return the count of items in the list +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_length import FnLengthResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnLengthResolverCanResolve: + """Tests for FnLengthResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnLengthResolver: + """Create a FnLengthResolver for testing.""" + return FnLengthResolver(context, None) + + def test_can_resolve_fn_length(self, resolver: FnLengthResolver): + """Test that can_resolve returns True for Fn::Length.""" + value = {"Fn::Length": [1, 2, 3]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnLengthResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnLengthResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnLengthResolver): + """Test that FUNCTION_NAMES contains Fn::Length.""" + assert FnLengthResolver.FUNCTION_NAMES == ["Fn::Length"] + + +class TestFnLengthResolverBasicFunctionality: + """Tests for basic Fn::Length functionality. + + Requirement 3.1: WHEN Fn::Length is applied to a list, THEN THE Resolver + SHALL return the number of elements in the list + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnLengthResolver: + """Create a FnLengthResolver for testing.""" + return FnLengthResolver(context, None) + + def test_length_of_empty_list(self, resolver: FnLengthResolver): + """Test Fn::Length with empty list returns 0. + + Requirement 3.1: Return the number of elements in the list + """ + value: Dict[str, Any] = {"Fn::Length": []} + assert resolver.resolve(value) == 0 + + def test_length_of_single_element_list(self, resolver: FnLengthResolver): + """Test Fn::Length with single element list returns 1. + + Requirement 3.1: Return the number of elements in the list + """ + value = {"Fn::Length": ["single"]} + assert resolver.resolve(value) == 1 + + def test_length_of_multiple_element_list(self, resolver: FnLengthResolver): + """Test Fn::Length with multiple elements returns correct count. + + Requirement 3.1: Return the number of elements in the list + """ + value = {"Fn::Length": [1, 2, 3, 4, 5]} + assert resolver.resolve(value) == 5 + + def test_length_of_list_with_mixed_types(self, resolver: FnLengthResolver): + """Test Fn::Length with mixed type elements. + + Requirement 3.1: Return the number of elements in the list + """ + value = {"Fn::Length": [1, "two", {"three": 3}, [4], None]} + assert resolver.resolve(value) == 5 + + def test_length_of_list_with_nested_lists(self, resolver: FnLengthResolver): + """Test Fn::Length counts top-level elements only. + + Requirement 3.1: Return the number of elements in the list + """ + value = {"Fn::Length": [[1, 2], [3, 4, 5], [6]]} + assert resolver.resolve(value) == 3 # 3 nested lists, not 6 elements + + +class TestFnLengthResolverErrorHandling: + """Tests for Fn::Length error handling. + + Requirement 3.3: WHEN Fn::Length is applied to a non-list value, THEN THE + Resolver SHALL raise an Invalid_Template_Exception indicating incorrect layout + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnLengthResolver: + """Create a FnLengthResolver for testing.""" + return FnLengthResolver(context, None) + + def test_non_list_string_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with string raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_non_list_integer_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with integer raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": 42} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_non_list_dict_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with dict raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": {"key": "value"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_non_list_none_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with None raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_non_list_boolean_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with boolean raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": True} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_non_list_float_raises_exception(self, resolver: FnLengthResolver): + """Test Fn::Length with float raises InvalidTemplateException. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + value = {"Fn::Length": 3.14} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_error_message_exact_format(self, resolver: FnLengthResolver): + """Test that error message matches exact expected format. + + Requirement 3.3: Raise Invalid_Template_Exception indicating incorrect layout + + The error message must be exactly "Fn::Length layout is incorrect" to match + the Kotlin implementation's error messages. + """ + value = {"Fn::Length": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + # Verify exact error message format + assert str(exc_info.value) == "Fn::Length layout is incorrect" + + +class MockListResolver(IntrinsicFunctionResolver): + """A mock resolver that returns a list for testing nested resolution.""" + + FUNCTION_NAMES = ["Fn::MockList"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return a list based on the argument.""" + args = self.get_function_args(value) + if isinstance(args, int): + return list(range(args)) + return args + + +class TestFnLengthResolverNestedIntrinsics: + """Tests for Fn::Length with nested intrinsic functions. + + Requirement 3.2: WHEN Fn::Length is applied to a nested intrinsic function + that resolves to a list, THEN THE Resolver SHALL first resolve the inner + function and then return the length + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnLengthResolver and MockListResolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnLengthResolver) + orchestrator.register_resolver(MockListResolver) + return orchestrator + + def test_nested_intrinsic_resolved_first(self, orchestrator: IntrinsicResolver): + """Test that nested intrinsic is resolved before length calculation. + + Requirement 3.2: Resolve inner function first, then return length + """ + # Fn::MockList with arg 5 returns [0, 1, 2, 3, 4] + value = {"Fn::Length": {"Fn::MockList": 5}} + result = orchestrator.resolve_value(value) + + assert result == 5 + + def test_nested_intrinsic_empty_list(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to empty list. + + Requirement 3.2: Resolve inner function first, then return length + """ + # Fn::MockList with arg 0 returns [] + value = {"Fn::Length": {"Fn::MockList": 0}} + result = orchestrator.resolve_value(value) + + assert result == 0 + + def test_nested_intrinsic_non_list_raises_exception(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to non-list raises exception. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + """ + # Fn::MockList with string arg returns the string as-is + value = {"Fn::Length": {"Fn::MockList": "not-a-list"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_nested_intrinsic_resolves_to_dict_raises_exception(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to dict raises exception. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + + This tests the case where a nested intrinsic resolves to a dictionary, + which is a common mistake when users confuse objects with arrays. + """ + # Fn::MockList with dict arg returns the dict as-is + value = {"Fn::Length": {"Fn::MockList": {"key": "value"}}} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + def test_nested_intrinsic_resolves_to_integer_raises_exception(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to integer raises exception. + + Requirement 3.3: Raise Invalid_Template_Exception for non-list input + + This tests the case where a nested intrinsic resolves to an integer, + which could happen if a user mistakenly uses a numeric parameter. + """ + # Create a mock that returns an integer + value = {"Fn::Length": {"Fn::MockList": {"not": "a list"}}} + + with pytest.raises(InvalidTemplateException) as exc_info: + orchestrator.resolve_value(value) + + assert "Fn::Length layout is incorrect" in str(exc_info.value) + + +class TestFnLengthResolverWithOrchestrator: + """Tests for FnLengthResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnLengthResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnLengthResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Length through the orchestrator.""" + value = {"Fn::Length": [1, 2, 3]} + result = orchestrator.resolve_value(value) + + assert result == 3 + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Length in a nested template structure.""" + value = { + "Resources": { + "MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"DelaySeconds": {"Fn::Length": [1, 2, 3, 4, 5]}}} + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == 5 + + def test_resolve_multiple_fn_length(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Length in same structure.""" + value = { + "first": {"Fn::Length": [1, 2]}, + "second": {"Fn::Length": [1, 2, 3, 4]}, + "third": {"Fn::Length": []}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "first": 2, + "second": 4, + "third": 0, + } + + def test_fn_length_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Length inside a list.""" + value = [ + {"Fn::Length": [1]}, + {"Fn::Length": [1, 2]}, + {"Fn::Length": [1, 2, 3]}, + ] + result = orchestrator.resolve_value(value) + + assert result == [1, 2, 3] + + +class TestFnLengthResolverPartialMode: + """Tests for FnLengthResolver in partial resolution mode. + + Fn::Length should always be resolved, even in partial mode. + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnLengthResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnLengthResolver) + return orchestrator + + def test_fn_length_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Length is resolved even in partial mode. + + Requirement 16.4: In partial mode, still resolve Fn::Length + """ + value = {"Fn::Length": [1, 2, 3]} + result = orchestrator.resolve_value(value) + + assert result == 3 + + def test_fn_length_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::Length alongside preserved intrinsics in partial mode.""" + value = { + "length": {"Fn::Length": [1, 2, 3]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "length": 3, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +# ============================================================================= +# Parametrized Tests for Fn::Length +# ============================================================================= + + +class MockRefResolver(IntrinsicFunctionResolver): + """A mock resolver that resolves Ref to parameter values for testing.""" + + FUNCTION_NAMES = ["Ref"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Resolve Ref to parameter values from context.""" + ref_target = self.get_function_args(value) + + # Check parameter_values in context + if ref_target in self.context.parameter_values: + return self.context.parameter_values[ref_target] + + # Return the Ref unchanged if not found + return value + + +class MockSplitResolver(IntrinsicFunctionResolver): + """A mock resolver that implements Fn::Split for testing nested intrinsics.""" + + FUNCTION_NAMES = ["Fn::Split"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Split a string by delimiter.""" + args = self.get_function_args(value) + if not isinstance(args, list) or len(args) != 2: + raise InvalidTemplateException("Fn::Split layout is incorrect") + + delimiter = args[0] + source_string = args[1] + + # Resolve nested intrinsics + if self.parent is not None: + source_string = self.parent.resolve_value(source_string) + + if not isinstance(source_string, str): + raise InvalidTemplateException("Fn::Split layout is incorrect") + + return source_string.split(delimiter) + + +class TestFnLengthPropertyBasedTests: + """ + Parametrized tests for Fn::Length intrinsic function. + + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + + These tests validate that for any list (including lists produced by resolving + nested intrinsic functions), Fn::Length SHALL return the exact count of elements + in that list. + + **Validates: Requirements 3.1, 3.2, 3.4** + """ + + @staticmethod + def _create_context() -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @staticmethod + def _create_orchestrator(context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnLengthResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnLengthResolver) + return orchestrator + + @pytest.mark.parametrize( + "items", + [ + [], + [1, "two", None, True, {"k": "v"}], + list(range(50)), + ], + ids=["empty-list", "mixed-types", "large-list"], + ) + def test_fn_length_returns_exact_count_for_any_list(self, items: List[Any]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::Length": items} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "items", + [ + [], + ["alpha", "beta", "gamma"], + ["subnet-abc", "subnet-def", "subnet-ghi", "subnet-jkl"], + ], + ids=["empty-strings", "three-strings", "four-subnet-ids"], + ) + def test_fn_length_returns_exact_count_for_string_lists(self, items: List[str]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::Length": items} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "items", + [ + [], + [10, 20, 30], + [-1, 0, 1, 2, 3, 4, 5], + ], + ids=["empty-ints", "three-ints", "seven-ints"], + ) + def test_fn_length_returns_exact_count_for_integer_lists(self, items: List[int]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::Length": items} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "delimiter,items", + [ + (",", ["a", "b", "c"]), + ("|", ["x", "y"]), + ("-", ["one", "two", "three", "four", "five"]), + ], + ids=["comma-3-items", "pipe-2-items", "dash-5-items"], + ) + def test_fn_length_with_nested_fn_split_returns_correct_count(self, delimiter: str, items: List[str]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.2** + """ + context = self._create_context() + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnLengthResolver) + orchestrator.register_resolver(MockSplitResolver) + + delimited_string = delimiter.join(items) + + value = {"Fn::Length": {"Fn::Split": [delimiter, delimited_string]}} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "items", + [ + [], + ["us-east-1", "us-west-2"], + ["a", "b", "c", "d", "e"], + ], + ids=["empty-cdl", "two-regions", "five-items"], + ) + def test_fn_length_with_comma_delimited_list_parameter(self, items: List[str]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, parameter_values={"MyCommaDelimitedList": items} + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnLengthResolver) + orchestrator.register_resolver(MockRefResolver) + + value = {"Fn::Length": {"Ref": "MyCommaDelimitedList"}} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "outer_items", + [ + [], + [[1, 2], [3, 4, 5]], + [[10], [20, 30], [40, 50, 60]], + ], + ids=["empty-nested", "two-nested-lists", "three-nested-lists"], + ) + def test_fn_length_counts_top_level_elements_only(self, outer_items: List[List[int]]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::Length": outer_items} + result = orchestrator.resolve_value(value) + + assert result == len(outer_items) + + @pytest.mark.parametrize( + "items", + [ + [], + [{"Key": "Env", "Value": "prod"}], + [{"Key": "A", "Value": "1"}, {"Key": "B", "Value": "2"}, {"Key": "C", "Value": "3"}], + ], + ids=["empty-dicts", "single-tag", "three-tags"], + ) + def test_fn_length_with_list_of_dicts(self, items: List[Dict[str, Any]]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::Length": items} + result = orchestrator.resolve_value(value) + + assert result == len(items) + + @pytest.mark.parametrize( + "items", + [ + [], + ["a", "b"], + [1, 2, 3, 4, 5], + ], + ids=["empty-in-template", "two-in-template", "five-in-template"], + ) + def test_fn_length_in_template_structure(self, items: List[Any]): + """ + Feature: cfn-language-extensions-python, Property 5: Fn::Length Returns List Length + **Validates: Requirements 3.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + template_value = { + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": {"DelaySeconds": {"Fn::Length": items}}, + } + } + } + + result = orchestrator.resolve_value(template_value) + + assert result["Resources"]["MyQueue"]["Properties"]["DelaySeconds"] == len(items) diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_ref.py b/tests/unit/lib/cfn_language_extensions/test_fn_ref.py new file mode 100644 index 00000000000..9556ecfa03f --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_ref.py @@ -0,0 +1,1201 @@ +""" +Unit tests for the FnRefResolver class. + +Tests cover: +- Parameter reference resolution +- Pseudo-parameter reference resolution +- Resource reference preservation in partial mode +- Error handling for invalid inputs + +Requirements: + - 10.1: WHEN Ref is applied to a template parameter, THEN THE Resolver SHALL + return the parameter's value from the context + - 9.2: WHEN a pseudo-parameter (AWS::Region, AWS::AccountId, etc.) is referenced, + THEN THE Resolver SHALL return the value from the PseudoParameterValues + if provided + - 9.3: WHEN a pseudo-parameter is referenced but no value is provided, THEN THE + Resolver SHALL preserve the reference unresolved +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, + PseudoParameterValues, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver, PSEUDO_PARAMETERS +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnRefResolverCanResolve: + """Tests for FnRefResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnRefResolver: + """Create a FnRefResolver for testing.""" + return FnRefResolver(context, None) + + def test_can_resolve_ref(self, resolver: FnRefResolver): + """Test that can_resolve returns True for Ref.""" + value = {"Ref": "MyParameter"} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnRefResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnRefResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnRefResolver): + """Test that FUNCTION_NAMES contains Ref.""" + assert FnRefResolver.FUNCTION_NAMES == ["Ref"] + + +class TestFnRefResolverParameterResolution: + """Tests for Ref parameter resolution. + + Requirement 10.1: WHEN Ref is applied to a template parameter, THEN THE + Resolver SHALL return the parameter's value from the context + """ + + @pytest.fixture + def context_with_params(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + "InstanceType": "t3.medium", + "EnableFeature": True, + "MaxCount": 10, + }, + ) + + @pytest.fixture + def resolver(self, context_with_params: TemplateProcessingContext) -> FnRefResolver: + """Create a FnRefResolver with parameter context.""" + return FnRefResolver(context_with_params, None) + + def test_resolve_string_parameter(self, resolver: FnRefResolver): + """Test resolving a string parameter. + + Requirement 10.1: Return parameter value from context + """ + value = {"Ref": "Environment"} + result = resolver.resolve(value) + assert result == "production" + + def test_resolve_another_string_parameter(self, resolver: FnRefResolver): + """Test resolving another string parameter. + + Requirement 10.1: Return parameter value from context + """ + value = {"Ref": "InstanceType"} + result = resolver.resolve(value) + assert result == "t3.medium" + + def test_resolve_boolean_parameter(self, resolver: FnRefResolver): + """Test resolving a boolean parameter. + + Requirement 10.1: Return parameter value from context + """ + value = {"Ref": "EnableFeature"} + result = resolver.resolve(value) + assert result is True + + def test_resolve_integer_parameter(self, resolver: FnRefResolver): + """Test resolving an integer parameter. + + Requirement 10.1: Return parameter value from context + """ + value = {"Ref": "MaxCount"} + result = resolver.resolve(value) + assert result == 10 + + def test_resolve_parameter_with_default(self): + """Test resolving a parameter with default value when no value provided. + + Requirement 10.1: Return parameter value from context + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=ParsedTemplate( + parameters={"Environment": {"Type": "String", "Default": "development"}}, resources={} + ), + ) + resolver = FnRefResolver(context, None) + + value = {"Ref": "Environment"} + result = resolver.resolve(value) + assert result == "development" + + +class TestFnRefResolverPseudoParameterResolution: + """Tests for Ref pseudo-parameter resolution. + + Requirement 9.2: WHEN a pseudo-parameter (AWS::Region, AWS::AccountId, etc.) + is referenced, THEN THE Resolver SHALL return the value from the + PseudoParameterValues if provided + """ + + @pytest.fixture + def context_with_pseudo_params(self) -> TemplateProcessingContext: + """Create a context with pseudo-parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + stack_id="arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid", + stack_name="my-stack", + notification_arns=["arn:aws:sns:us-west-2:123456789012:my-topic"], + ), + ) + + @pytest.fixture + def resolver(self, context_with_pseudo_params: TemplateProcessingContext) -> FnRefResolver: + """Create a FnRefResolver with pseudo-parameter context.""" + return FnRefResolver(context_with_pseudo_params, None) + + def test_resolve_aws_region(self, resolver: FnRefResolver): + """Test resolving AWS::Region pseudo-parameter. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::Region"} + result = resolver.resolve(value) + assert result == "us-west-2" + + def test_resolve_aws_account_id(self, resolver: FnRefResolver): + """Test resolving AWS::AccountId pseudo-parameter. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::AccountId"} + result = resolver.resolve(value) + assert result == "123456789012" + + def test_resolve_aws_stack_id(self, resolver: FnRefResolver): + """Test resolving AWS::StackId pseudo-parameter. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::StackId"} + result = resolver.resolve(value) + assert result == "arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid" + + def test_resolve_aws_stack_name(self, resolver: FnRefResolver): + """Test resolving AWS::StackName pseudo-parameter. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::StackName"} + result = resolver.resolve(value) + assert result == "my-stack" + + def test_resolve_aws_notification_arns(self, resolver: FnRefResolver): + """Test resolving AWS::NotificationARNs pseudo-parameter. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::NotificationARNs"} + result = resolver.resolve(value) + assert result == ["arn:aws:sns:us-west-2:123456789012:my-topic"] + + def test_resolve_aws_partition_derived(self, resolver: FnRefResolver): + """Test resolving AWS::Partition derived from region. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::Partition"} + result = resolver.resolve(value) + assert result == "aws" + + def test_resolve_aws_url_suffix_derived(self, resolver: FnRefResolver): + """Test resolving AWS::URLSuffix derived from region. + + Requirement 9.2: Return pseudo-parameter value if provided + """ + value = {"Ref": "AWS::URLSuffix"} + result = resolver.resolve(value) + assert result == "amazonaws.com" + + +class TestFnRefResolverPartitionDerivation: + """Tests for AWS::Partition and AWS::URLSuffix derivation from region.""" + + def test_partition_for_standard_region(self): + """Test partition derivation for standard AWS region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-east-1", + account_id="123456789012", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws" + + def test_partition_for_china_region(self): + """Test partition derivation for China region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="cn-north-1", + account_id="123456789012", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws-cn" + + def test_partition_for_govcloud_region(self): + """Test partition derivation for GovCloud region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-gov-west-1", + account_id="123456789012", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws-us-gov" + + def test_url_suffix_for_standard_region(self): + """Test URL suffix derivation for standard AWS region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="eu-west-1", + account_id="123456789012", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "amazonaws.com" + + def test_url_suffix_for_china_region(self): + """Test URL suffix derivation for China region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="cn-northwest-1", + account_id="123456789012", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "amazonaws.com.cn" + + def test_explicit_partition_overrides_derived(self): + """Test that explicit partition value overrides derived value.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-east-1", + account_id="123456789012", + partition="custom-partition", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "custom-partition" + + def test_explicit_url_suffix_overrides_derived(self): + """Test that explicit URL suffix value overrides derived value.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-east-1", + account_id="123456789012", + url_suffix="custom.amazonaws.com", + ), + ) + resolver = FnRefResolver(context, None) + + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "custom.amazonaws.com" + + +class TestFnRefResolverPreserveUnresolved: + """Tests for preserving unresolved references. + + Requirement 9.3: WHEN a pseudo-parameter is referenced but no value is + provided, THEN THE Resolver SHALL preserve the reference unresolved + """ + + @pytest.fixture + def context_no_pseudo_params(self) -> TemplateProcessingContext: + """Create a context without pseudo-parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + + @pytest.fixture + def resolver(self, context_no_pseudo_params: TemplateProcessingContext) -> FnRefResolver: + """Create a FnRefResolver without pseudo-parameters.""" + return FnRefResolver(context_no_pseudo_params, None) + + def test_preserve_aws_region_without_value(self, resolver: FnRefResolver): + """Test that AWS::Region is preserved when no value provided. + + Requirement 9.3: Preserve pseudo-parameter reference if no value provided + """ + value = {"Ref": "AWS::Region"} + result = resolver.resolve(value) + assert result == {"Ref": "AWS::Region"} + + def test_preserve_aws_account_id_without_value(self, resolver: FnRefResolver): + """Test that AWS::AccountId is preserved when no value provided. + + Requirement 9.3: Preserve pseudo-parameter reference if no value provided + """ + value = {"Ref": "AWS::AccountId"} + result = resolver.resolve(value) + assert result == {"Ref": "AWS::AccountId"} + + def test_preserve_aws_stack_name_without_value(self, resolver: FnRefResolver): + """Test that AWS::StackName is preserved when no value provided. + + Requirement 9.3: Preserve pseudo-parameter reference if no value provided + """ + value = {"Ref": "AWS::StackName"} + result = resolver.resolve(value) + assert result == {"Ref": "AWS::StackName"} + + def test_preserve_resource_reference(self, resolver: FnRefResolver): + """Test that resource references are preserved. + + Resource references cannot be resolved locally and should be preserved. + """ + value = {"Ref": "MyBucket"} + result = resolver.resolve(value) + assert result == {"Ref": "MyBucket"} + + def test_preserve_unknown_reference(self, resolver: FnRefResolver): + """Test that unknown references are preserved.""" + value = {"Ref": "UnknownResource"} + result = resolver.resolve(value) + assert result == {"Ref": "UnknownResource"} + + +class TestFnRefResolverPartialMode: + """Tests for FnRefResolver in partial resolution mode. + + In partial mode, resource references should be preserved while + parameter and pseudo-parameter references should be resolved. + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parameter_values={"Environment": "prod"}, + pseudo_parameters=PseudoParameterValues( + region="us-east-1", + account_id="123456789012", + ), + parsed_template=ParsedTemplate( + parameters={"Environment": {"Type": "String"}}, resources={"MyBucket": {"Type": "AWS::S3::Bucket"}} + ), + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnRefResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnRefResolver) + return orchestrator + + def test_parameter_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that parameters are resolved in partial mode.""" + value = {"Ref": "Environment"} + result = orchestrator.resolve_value(value) + assert result == "prod" + + def test_pseudo_parameter_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that pseudo-parameters are resolved in partial mode.""" + value = {"Ref": "AWS::Region"} + result = orchestrator.resolve_value(value) + assert result == "us-east-1" + + def test_resource_reference_preserved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that resource references are preserved in partial mode.""" + value = {"Ref": "MyBucket"} + result = orchestrator.resolve_value(value) + assert result == {"Ref": "MyBucket"} + + +class TestFnRefResolverErrorHandling: + """Tests for FnRefResolver error handling.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnRefResolver: + """Create a FnRefResolver for testing.""" + return FnRefResolver(context, None) + + def test_non_string_ref_target_raises_exception(self, resolver: FnRefResolver): + """Test that non-string Ref target raises InvalidTemplateException.""" + value = {"Ref": 123} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Ref layout is incorrect" in str(exc_info.value) + + def test_list_ref_target_raises_exception(self, resolver: FnRefResolver): + """Test that list Ref target raises InvalidTemplateException.""" + value = {"Ref": ["a", "b"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Ref layout is incorrect" in str(exc_info.value) + + def test_dict_ref_target_raises_exception(self, resolver: FnRefResolver): + """Test that dict Ref target raises InvalidTemplateException.""" + value = {"Ref": {"key": "value"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Ref layout is incorrect" in str(exc_info.value) + + def test_none_ref_target_raises_exception(self, resolver: FnRefResolver): + """Test that None Ref target raises InvalidTemplateException.""" + value = {"Ref": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Ref layout is incorrect" in str(exc_info.value) + + +class TestFnRefResolverWithOrchestrator: + """Tests for FnRefResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameters and pseudo-parameters.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + "BucketName": "my-bucket", + }, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + ), + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnRefResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnRefResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Ref through the orchestrator.""" + value = {"Ref": "Environment"} + result = orchestrator.resolve_value(value) + assert result == "production" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Ref in a nested template structure.""" + value = { + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": {"Ref": "BucketName"}}}} + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "my-bucket" + + def test_resolve_multiple_refs(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Refs in same structure.""" + value = { + "env": {"Ref": "Environment"}, + "region": {"Ref": "AWS::Region"}, + "account": {"Ref": "AWS::AccountId"}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "env": "production", + "region": "us-west-2", + "account": "123456789012", + } + + def test_ref_in_list(self, orchestrator: IntrinsicResolver): + """Test Ref inside a list.""" + value = [ + {"Ref": "Environment"}, + {"Ref": "AWS::Region"}, + {"Ref": "BucketName"}, + ] + result = orchestrator.resolve_value(value) + + assert result == ["production", "us-west-2", "my-bucket"] + + +class TestPseudoParametersConstant: + """Tests for the PSEUDO_PARAMETERS constant.""" + + def test_contains_all_pseudo_parameters(self): + """Test that PSEUDO_PARAMETERS contains all expected pseudo-parameters.""" + expected = { + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", + } + assert PSEUDO_PARAMETERS == expected + + +# ============================================================================= +# Parametrized Tests for Pseudo-Parameter Resolution +# ============================================================================= + + +def _make_stack_id(region: str, account_id: str, stack_name: str) -> str: + """Generate a valid CloudFormation stack ID ARN.""" + return f"arn:aws:cloudformation:{region}:{account_id}:stack/{stack_name}/guid-1234-5678" + + +class TestFnRefPseudoParameterPropertyBasedTests: + """ + Parametrized tests for pseudo-parameter resolution. + + Feature: cfn-language-extensions-python, Property 14: Pseudo-Parameter Resolution + + These tests validate that for any Ref to a pseudo-parameter (AWS::Region, + AWS::AccountId, etc.) where a value is provided in the context, the resolver + SHALL substitute that value; where no value is provided, the Ref SHALL be + preserved unresolved. + + **Validates: Requirements 9.2, 9.3** + """ + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("cn-north-1", "987654321098"), + ("us-gov-west-1", "111222333444"), + ], + ) + def test_aws_region_resolved_when_provided(self, region: str, account_id: str): + """ + Property 14: For any Ref to AWS::Region where a value is provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Region"}) + assert result == region + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-west-2", "123456789012"), + ("eu-west-1", "999888777666"), + ("ap-northeast-1", "555444333222"), + ], + ) + def test_aws_account_id_resolved_when_provided(self, region: str, account_id: str): + """ + Property 14: For any Ref to AWS::AccountId where a value is provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::AccountId"}) + assert result == account_id + + @pytest.mark.parametrize( + "region, account_id, stack_name", + [ + ("us-east-1", "123456789012", "my-stack"), + ("eu-west-1", "999888777666", "prod-app-stack"), + ("ap-southeast-1", "555444333222", "A1-test"), + ], + ) + def test_aws_stack_name_resolved_when_provided(self, region: str, account_id: str, stack_name: str): + """ + Property 14: For any Ref to AWS::StackName where a value is provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, stack_name=stack_name), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::StackName"}) + assert result == stack_name + + @pytest.mark.parametrize( + "region, account_id, stack_name", + [ + ("us-east-1", "123456789012", "my-stack"), + ("eu-central-1", "999888777666", "prod-stack"), + ("us-gov-east-1", "555444333222", "gov-stack"), + ], + ) + def test_aws_stack_id_resolved_when_provided(self, region: str, account_id: str, stack_name: str): + """ + Property 14: For any Ref to AWS::StackId where a value is provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + stack_id = _make_stack_id(region, account_id, stack_name) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, stack_id=stack_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::StackId"}) + assert result == stack_id + + @pytest.mark.parametrize( + "region, account_id, partition", + [ + ("us-east-1", "123456789012", "aws"), + ("cn-north-1", "987654321098", "aws-cn"), + ("us-gov-west-1", "555444333222", "aws-us-gov"), + ], + ) + def test_aws_partition_resolved_when_explicitly_provided(self, region: str, account_id: str, partition: str): + """ + Property 14: For any Ref to AWS::Partition where a value is explicitly provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, partition=partition), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == partition + + @pytest.mark.parametrize( + "region, account_id, url_suffix", + [ + ("us-east-1", "123456789012", "amazonaws.com"), + ("cn-north-1", "987654321098", "amazonaws.com.cn"), + ("us-gov-west-1", "555444333222", "amazonaws.com"), + ], + ) + def test_aws_url_suffix_resolved_when_explicitly_provided(self, region: str, account_id: str, url_suffix: str): + """ + Property 14: For any Ref to AWS::URLSuffix where a value is explicitly provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, url_suffix=url_suffix), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == url_suffix + + @pytest.mark.parametrize( + "pseudo_param", + [ + "AWS::Region", + "AWS::AccountId", + "AWS::StackName", + "AWS::StackId", + "AWS::Partition", + "AWS::URLSuffix", + "AWS::NotificationARNs", + ], + ) + def test_pseudo_parameter_preserved_when_no_context_provided(self, pseudo_param: str): + """ + Property 14: For any Ref to a pseudo-parameter where no PseudoParameterValues + context is provided, the resolver SHALL preserve the Ref unresolved. + + **Validates: Requirements 9.3** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=None, + ) + resolver = FnRefResolver(context, None) + value = {"Ref": pseudo_param} + result = resolver.resolve(value) + assert result == value + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("eu-west-1", "999888777666"), + ("ap-south-1", "555444333222"), + ], + ) + def test_aws_stack_name_preserved_when_not_provided(self, region: str, account_id: str): + """ + Property 14: For any Ref to AWS::StackName where the stack_name is not provided, + the resolver SHALL preserve the Ref unresolved. + + **Validates: Requirements 9.3** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, stack_name=None), + ) + resolver = FnRefResolver(context, None) + value = {"Ref": "AWS::StackName"} + result = resolver.resolve(value) + assert result == value + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-west-2", "123456789012"), + ("cn-northwest-1", "987654321098"), + ], + ) + def test_aws_stack_id_preserved_when_not_provided(self, region: str, account_id: str): + """ + Property 14: For any Ref to AWS::StackId where the stack_id is not provided, + the resolver SHALL preserve the Ref unresolved. + + **Validates: Requirements 9.3** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, stack_id=None), + ) + resolver = FnRefResolver(context, None) + value = {"Ref": "AWS::StackId"} + result = resolver.resolve(value) + assert result == value + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("eu-central-1", "999888777666"), + ], + ) + def test_aws_notification_arns_preserved_when_not_provided(self, region: str, account_id: str): + """ + Property 14: For any Ref to AWS::NotificationARNs where the notification_arns + is not provided, the resolver SHALL preserve the Ref unresolved. + + **Validates: Requirements 9.3** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id, notification_arns=None), + ) + resolver = FnRefResolver(context, None) + value = {"Ref": "AWS::NotificationARNs"} + result = resolver.resolve(value) + assert result == value + + @pytest.mark.parametrize( + "region, account_id, num_arns", + [ + ("us-east-1", "123456789012", 1), + ("eu-west-1", "999888777666", 3), + ("ap-northeast-1", "555444333222", 5), + ], + ) + def test_aws_notification_arns_resolved_when_provided(self, region: str, account_id: str, num_arns: int): + """ + Property 14: For any Ref to AWS::NotificationARNs where a value is provided, + the resolver SHALL substitute that value. + + **Validates: Requirements 9.2** + """ + notification_arns = [f"arn:aws:sns:{region}:{account_id}:topic-{i}" for i in range(num_arns)] + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region=region, account_id=account_id, notification_arns=notification_arns + ), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::NotificationARNs"}) + assert result == notification_arns + + @pytest.mark.parametrize( + "region, account_id, stack_name", + [ + ("us-east-1", "123456789012", "my-stack"), + ("eu-west-2", "999888777666", "prod-app"), + ("ap-southeast-2", "555444333222", "test-stack"), + ], + ) + def test_all_provided_pseudo_parameters_resolved_correctly(self, region: str, account_id: str, stack_name: str): + """ + Property 14: For any template with multiple Refs to different pseudo-parameters + where values are provided, all SHALL be resolved to their respective values. + + **Validates: Requirements 9.2** + """ + stack_id = _make_stack_id(region, account_id, stack_name) + notification_arns = [f"arn:aws:sns:{region}:{account_id}:my-topic"] + + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region=region, + account_id=account_id, + stack_id=stack_id, + stack_name=stack_name, + notification_arns=notification_arns, + ), + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnRefResolver) + + template_value = { + "region": {"Ref": "AWS::Region"}, + "account": {"Ref": "AWS::AccountId"}, + "stack_name": {"Ref": "AWS::StackName"}, + "stack_id": {"Ref": "AWS::StackId"}, + "notification_arns": {"Ref": "AWS::NotificationARNs"}, + } + + result = orchestrator.resolve_value(template_value) + + assert result["region"] == region + assert result["account"] == account_id + assert result["stack_name"] == stack_name + assert result["stack_id"] == stack_id + assert result["notification_arns"] == notification_arns + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("eu-west-1", "999888777666"), + ("ap-south-1", "555444333222"), + ], + ) + def test_mixed_provided_and_unprovided_pseudo_parameters(self, region: str, account_id: str): + """ + Property 14: For any template with Refs to both provided and unprovided + pseudo-parameters, provided values SHALL be resolved while unprovided + values SHALL be preserved. + + **Validates: Requirements 9.2, 9.3** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnRefResolver) + + template_value = { + "region": {"Ref": "AWS::Region"}, + "account": {"Ref": "AWS::AccountId"}, + "stack_name": {"Ref": "AWS::StackName"}, + "stack_id": {"Ref": "AWS::StackId"}, + } + + result = orchestrator.resolve_value(template_value) + + assert result["region"] == region + assert result["account"] == account_id + assert result["stack_name"] == {"Ref": "AWS::StackName"} + assert result["stack_id"] == {"Ref": "AWS::StackId"} + + +# ============================================================================= +# Parametrized Tests for Partition Derivation from Region +# ============================================================================= + + +class TestFnRefPartitionDerivationPropertyBasedTests: + """ + Parametrized tests for partition derivation from region. + + Feature: cfn-language-extensions-python, Property 15: Partition Derivation from Region + + **Validates: Requirements 9.4** + """ + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("eu-west-1", "999888777666"), + ("ap-southeast-2", "555444333222"), + ], + ) + def test_standard_region_derives_aws_partition(self, region: str, account_id: str): + """ + Property 15: For any standard AWS region, the resolver SHALL derive + AWS::Partition as "aws". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws" + + @pytest.mark.parametrize( + "region, account_id", + [ + ("cn-north-1", "123456789012"), + ("cn-northwest-1", "987654321098"), + ], + ) + def test_china_region_derives_aws_cn_partition(self, region: str, account_id: str): + """ + Property 15: For any China region, the resolver SHALL derive + AWS::Partition as "aws-cn". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws-cn" + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-gov-west-1", "123456789012"), + ("us-gov-east-1", "987654321098"), + ], + ) + def test_govcloud_region_derives_aws_us_gov_partition(self, region: str, account_id: str): + """ + Property 15: For any GovCloud region, the resolver SHALL derive + AWS::Partition as "aws-us-gov". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == "aws-us-gov" + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-east-1", "123456789012"), + ("eu-central-1", "999888777666"), + ("sa-east-1", "555444333222"), + ], + ) + def test_standard_region_derives_amazonaws_com_url_suffix(self, region: str, account_id: str): + """ + Property 15: For any standard AWS region, the resolver SHALL derive + AWS::URLSuffix as "amazonaws.com". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "amazonaws.com" + + @pytest.mark.parametrize( + "region, account_id", + [ + ("cn-north-1", "123456789012"), + ("cn-northwest-1", "987654321098"), + ], + ) + def test_china_region_derives_amazonaws_com_cn_url_suffix(self, region: str, account_id: str): + """ + Property 15: For any China region, the resolver SHALL derive + AWS::URLSuffix as "amazonaws.com.cn". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "amazonaws.com.cn" + + @pytest.mark.parametrize( + "region, account_id", + [ + ("us-gov-west-1", "123456789012"), + ("us-gov-east-1", "987654321098"), + ], + ) + def test_govcloud_region_derives_amazonaws_com_url_suffix(self, region: str, account_id: str): + """ + Property 15: For any GovCloud region, the resolver SHALL derive + AWS::URLSuffix as "amazonaws.com". + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id=account_id), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == "amazonaws.com" + + @pytest.mark.parametrize( + "region, expected_partition", + [ + ("us-east-1", "aws"), + ("eu-west-1", "aws"), + ("cn-north-1", "aws-cn"), + ("cn-northwest-1", "aws-cn"), + ("us-gov-west-1", "aws-us-gov"), + ("us-gov-east-1", "aws-us-gov"), + ], + ) + def test_partition_derivation_consistent_with_region_prefix(self, region: str, expected_partition: str): + """ + Property 15: For any AWS region, the derived partition SHALL be consistent + with the region's prefix. + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id="123456789012"), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::Partition"}) + assert result == expected_partition + + @pytest.mark.parametrize( + "region, expected_url_suffix", + [ + ("us-east-1", "amazonaws.com"), + ("eu-west-1", "amazonaws.com"), + ("cn-north-1", "amazonaws.com.cn"), + ("cn-northwest-1", "amazonaws.com.cn"), + ("us-gov-west-1", "amazonaws.com"), + ("us-gov-east-1", "amazonaws.com"), + ], + ) + def test_url_suffix_derivation_consistent_with_region_prefix(self, region: str, expected_url_suffix: str): + """ + Property 15: For any AWS region, the derived URL suffix SHALL be consistent + with the region's prefix. + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id="123456789012"), + ) + resolver = FnRefResolver(context, None) + result = resolver.resolve({"Ref": "AWS::URLSuffix"}) + assert result == expected_url_suffix + + @pytest.mark.parametrize( + "region, expected_partition, expected_url_suffix", + [ + ("us-east-1", "aws", "amazonaws.com"), + ("cn-north-1", "aws-cn", "amazonaws.com.cn"), + ("us-gov-west-1", "aws-us-gov", "amazonaws.com"), + ], + ) + def test_partition_and_url_suffix_both_derived_correctly( + self, region: str, expected_partition: str, expected_url_suffix: str + ): + """ + Property 15: For any AWS region, both AWS::Partition and AWS::URLSuffix + SHALL be correctly derived when not explicitly provided. + + **Validates: Requirements 9.4** + """ + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues(region=region, account_id="123456789012"), + ) + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnRefResolver) + + template_value = { + "partition": {"Ref": "AWS::Partition"}, + "url_suffix": {"Ref": "AWS::URLSuffix"}, + } + + result = orchestrator.resolve_value(template_value) + + assert result["partition"] == expected_partition + assert result["url_suffix"] == expected_url_suffix diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_select.py b/tests/unit/lib/cfn_language_extensions/test_fn_select.py new file mode 100644 index 00000000000..086f7e48ee6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_select.py @@ -0,0 +1,613 @@ +""" +Unit tests for the FnSelectResolver class. + +Tests cover: +- Basic Fn::Select functionality with literal lists +- Nested intrinsic function resolution +- Error handling for invalid inputs and out-of-bounds index +- Integration with IntrinsicResolver orchestrator + +Requirements: + - 10.5: WHEN Fn::Select is applied to a list with an index, THEN THE + Resolver SHALL return the element at that index + - 10.9: WHEN Fn::Select is applied with an out-of-bounds index, THEN THE + Resolver SHALL raise an Invalid_Template_Exception +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_select import FnSelectResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_split import FnSplitResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_join import FnJoinResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnSelectResolverCanResolve: + """Tests for FnSelectResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSelectResolver: + """Create a FnSelectResolver for testing.""" + return FnSelectResolver(context, None) + + def test_can_resolve_fn_select(self, resolver: FnSelectResolver): + """Test that can_resolve returns True for Fn::Select.""" + value = {"Fn::Select": [0, ["a", "b", "c"]]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnSelectResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + assert resolver.can_resolve({"Fn::Split": [",", "a,b"]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnSelectResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnSelectResolver): + """Test that FUNCTION_NAMES contains Fn::Select.""" + assert FnSelectResolver.FUNCTION_NAMES == ["Fn::Select"] + + +class TestFnSelectResolverBasicFunctionality: + """Tests for basic Fn::Select functionality. + + Requirement 10.5: WHEN Fn::Select is applied to a list with an index, + THEN THE Resolver SHALL return the element at that index + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSelectResolver: + """Create a FnSelectResolver for testing.""" + return FnSelectResolver(context, None) + + def test_select_first_element(self, resolver: FnSelectResolver): + """Test Fn::Select with index 0 returns first element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [0, ["a", "b", "c"]]} + assert resolver.resolve(value) == "a" + + def test_select_middle_element(self, resolver: FnSelectResolver): + """Test Fn::Select with middle index returns correct element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [1, ["a", "b", "c"]]} + assert resolver.resolve(value) == "b" + + def test_select_last_element(self, resolver: FnSelectResolver): + """Test Fn::Select with last index returns last element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [2, ["a", "b", "c"]]} + assert resolver.resolve(value) == "c" + + def test_select_from_single_element_list(self, resolver: FnSelectResolver): + """Test Fn::Select from single element list. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [0, ["only"]]} + assert resolver.resolve(value) == "only" + + def test_select_integer_element(self, resolver: FnSelectResolver): + """Test Fn::Select returns integer element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [1, [10, 20, 30]]} + assert resolver.resolve(value) == 20 + + def test_select_dict_element(self, resolver: FnSelectResolver): + """Test Fn::Select returns dict element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [0, [{"key": "value"}, "other"]]} + assert resolver.resolve(value) == {"key": "value"} + + def test_select_list_element(self, resolver: FnSelectResolver): + """Test Fn::Select returns nested list element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [1, ["first", ["nested", "list"]]]} + assert resolver.resolve(value) == ["nested", "list"] + + def test_select_with_string_index(self, resolver: FnSelectResolver): + """Test Fn::Select with string index that can be converted to int. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": ["1", ["a", "b", "c"]]} + assert resolver.resolve(value) == "b" + + def test_select_with_string_index_zero(self, resolver: FnSelectResolver): + """Test Fn::Select with string index "0". + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": ["0", ["first", "second"]]} + assert resolver.resolve(value) == "first" + + def test_select_null_element(self, resolver: FnSelectResolver): + """Test Fn::Select returns null element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [1, ["a", None, "c"]]} + assert resolver.resolve(value) is None + + def test_select_boolean_element(self, resolver: FnSelectResolver): + """Test Fn::Select returns boolean element. + + Requirement 10.5: Return the element at that index + """ + value = {"Fn::Select": [0, [True, False]]} + assert resolver.resolve(value) is True + + +class TestFnSelectResolverOutOfBounds: + """Tests for Fn::Select out-of-bounds error handling. + + Requirement 10.9: WHEN Fn::Select is applied with an out-of-bounds index, + THEN THE Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSelectResolver: + """Create a FnSelectResolver for testing.""" + return FnSelectResolver(context, None) + + def test_index_equals_list_length_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with index equal to list length raises exception. + + Requirement 10.9: Raise exception for out-of-bounds index + """ + value = {"Fn::Select": [3, ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select index out of bounds" in str(exc_info.value) + + def test_index_greater_than_list_length_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with index greater than list length raises exception. + + Requirement 10.9: Raise exception for out-of-bounds index + """ + value = {"Fn::Select": [10, ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select index out of bounds" in str(exc_info.value) + + def test_negative_index_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with negative index raises exception. + + Requirement 10.9: Raise exception for out-of-bounds index + """ + value = {"Fn::Select": [-1, ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select index out of bounds" in str(exc_info.value) + + def test_index_on_empty_list_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select on empty list raises exception. + + Requirement 10.9: Raise exception for out-of-bounds index + """ + value = {"Fn::Select": [0, []]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select index out of bounds" in str(exc_info.value) + + def test_string_index_out_of_bounds_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with string index out of bounds raises exception. + + Requirement 10.9: Raise exception for out-of-bounds index + """ + value = {"Fn::Select": ["5", ["a", "b"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select index out of bounds" in str(exc_info.value) + + +class TestFnSelectResolverErrorHandling: + """Tests for Fn::Select error handling for invalid layouts.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSelectResolver: + """Create a FnSelectResolver for testing.""" + return FnSelectResolver(context, None) + + def test_non_list_args_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with non-list args raises InvalidTemplateException.""" + value = {"Fn::Select": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_single_element_args_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with single element args raises InvalidTemplateException.""" + value = {"Fn::Select": [0]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_three_element_args_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with three element args raises InvalidTemplateException.""" + value = {"Fn::Select": [0, ["a", "b"], "extra"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_non_integer_index_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with non-integer index raises InvalidTemplateException.""" + value = {"Fn::Select": ["abc", ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_float_index_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with float index raises InvalidTemplateException.""" + value = {"Fn::Select": [1.5, ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_non_list_source_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with non-list source raises InvalidTemplateException.""" + value = {"Fn::Select": [0, "not-a-list"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_dict_source_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with dict source raises InvalidTemplateException.""" + value = {"Fn::Select": [0, {"key": "value"}]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_none_args_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with None args raises InvalidTemplateException.""" + value = {"Fn::Select": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_dict_args_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with dict args raises InvalidTemplateException.""" + value = {"Fn::Select": {"index": 0, "list": ["a", "b"]}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_list_index_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with list as index raises InvalidTemplateException.""" + value = {"Fn::Select": [[0], ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + def test_none_index_raises_exception(self, resolver: FnSelectResolver): + """Test Fn::Select with None index raises InvalidTemplateException.""" + value = {"Fn::Select": [None, ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Select layout is incorrect" in str(exc_info.value) + + +class TestFnSelectResolverNestedIntrinsics: + """Tests for Fn::Select with nested intrinsic functions.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Index": "1", + "MyList": "a,b,c", + }, + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSelectResolver and other resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSelectResolver) + orchestrator.register_resolver(FnRefResolver) + orchestrator.register_resolver(FnSplitResolver) + orchestrator.register_resolver(FnJoinResolver) + return orchestrator + + def test_nested_split_for_source_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Select with Fn::Split resolving to source list.""" + value = {"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]} + result = orchestrator.resolve_value(value) + assert result == "b" + + def test_nested_ref_for_index(self, orchestrator: IntrinsicResolver): + """Test Fn::Select with Ref resolving to index.""" + value = {"Fn::Select": [{"Ref": "Index"}, ["x", "y", "z"]]} + result = orchestrator.resolve_value(value) + assert result == "y" + + def test_nested_split_and_ref(self, orchestrator: IntrinsicResolver): + """Test Fn::Select with both nested Fn::Split and Ref.""" + value = {"Fn::Select": [{"Ref": "Index"}, {"Fn::Split": [",", "x,y,z"]}]} + result = orchestrator.resolve_value(value) + assert result == "y" + + def test_select_from_ref_comma_delimited_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Select from a Ref that resolves to comma-delimited list.""" + value = {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "MyList"}]}]} + result = orchestrator.resolve_value(value) + assert result == "a" + + +class TestFnSelectResolverWithOrchestrator: + """Tests for FnSelectResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSelectResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSelectResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Select through the orchestrator.""" + value = {"Fn::Select": [0, ["a", "b", "c"]]} + result = orchestrator.resolve_value(value) + assert result == "a" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Select in a nested template structure.""" + value = { + "Resources": { + "MyResource": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "Properties": {"SelectedValue": {"Fn::Select": [1, ["first", "second", "third"]]}}, + } + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyResource"]["Properties"]["SelectedValue"] == "second" + + def test_resolve_multiple_fn_select(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Select in same structure.""" + value = { + "first": {"Fn::Select": [0, ["a", "b"]]}, + "second": {"Fn::Select": [1, ["x", "y", "z"]]}, + "third": {"Fn::Select": [2, [1, 2, 3]]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "first": "a", + "second": "y", + "third": 3, + } + + def test_fn_select_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Select inside a list.""" + value = [ + {"Fn::Select": [0, ["a"]]}, + {"Fn::Select": [0, ["b", "c"]]}, + {"Fn::Select": [2, ["x", "y", "z"]]}, + ] + result = orchestrator.resolve_value(value) + + assert result == ["a", "b", "z"] + + +class TestFnSelectResolverPartialMode: + """Tests for FnSelectResolver in partial resolution mode.""" + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnSelectResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnSelectResolver) + return orchestrator + + def test_fn_select_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Select is resolved even in partial mode.""" + value = {"Fn::Select": [1, ["a", "b", "c"]]} + result = orchestrator.resolve_value(value) + + assert result == "b" + + def test_fn_select_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::Select alongside preserved intrinsics in partial mode.""" + value = { + "selected": {"Fn::Select": [0, ["first", "second"]]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "selected": "first", + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +class TestFnSelectResolverRealWorldExamples: + """Tests for Fn::Select with real-world CloudFormation patterns.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with multiple resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSelectResolver) + orchestrator.register_resolver(FnSplitResolver) + return orchestrator + + def test_select_availability_zone(self, orchestrator: IntrinsicResolver): + """Test selecting an availability zone from a list.""" + value = {"Fn::Select": [0, ["us-east-1a", "us-east-1b", "us-east-1c"]]} + assert orchestrator.resolve_value(value) == "us-east-1a" + + def test_select_subnet_from_comma_delimited(self, orchestrator: IntrinsicResolver): + """Test selecting a subnet from comma-delimited list.""" + value = {"Fn::Select": [1, {"Fn::Split": [",", "subnet-123,subnet-456,subnet-789"]}]} + assert orchestrator.resolve_value(value) == "subnet-456" + + def test_select_from_arn_parts(self, orchestrator: IntrinsicResolver): + """Test selecting a part from a split ARN.""" + # Select the service name from an ARN + value = {"Fn::Select": [2, {"Fn::Split": [":", "arn:aws:s3:::my-bucket"]}]} + assert orchestrator.resolve_value(value) == "s3" + + def test_select_region_from_arn(self, orchestrator: IntrinsicResolver): + """Test selecting region from an ARN (empty for S3).""" + value = {"Fn::Select": [3, {"Fn::Split": [":", "arn:aws:s3:::my-bucket"]}]} + assert orchestrator.resolve_value(value) == "" + + def test_select_bucket_name_from_arn(self, orchestrator: IntrinsicResolver): + """Test selecting bucket name from an S3 ARN.""" + value = {"Fn::Select": [5, {"Fn::Split": [":", "arn:aws:s3:::my-bucket"]}]} + assert orchestrator.resolve_value(value) == "my-bucket" + + def test_select_cidr_octet(self, orchestrator: IntrinsicResolver): + """Test selecting an octet from a CIDR block.""" + value = {"Fn::Select": [0, {"Fn::Split": [".", "10.0.0.0"]}]} + assert orchestrator.resolve_value(value) == "10" + + +class TestFnSelectAndJoinIntegration: + """Tests for Fn::Select and Fn::Join working together.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with both resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSelectResolver) + orchestrator.register_resolver(FnJoinResolver) + orchestrator.register_resolver(FnSplitResolver) + return orchestrator + + def test_join_selected_elements(self, orchestrator: IntrinsicResolver): + """Test joining selected elements.""" + value = { + "Fn::Join": [ + "-", + [ + {"Fn::Select": [0, ["prefix", "other"]]}, + {"Fn::Select": [1, ["x", "middle", "y"]]}, + {"Fn::Select": [0, ["suffix"]]}, + ], + ] + } + result = orchestrator.resolve_value(value) + assert result == "prefix-middle-suffix" + + def test_select_from_split_then_join(self, orchestrator: IntrinsicResolver): + """Test selecting from split result and joining.""" + # Split "a-b-c", select first two, join with comma + value = { + "Fn::Join": [ + ",", + [ + {"Fn::Select": [0, {"Fn::Split": ["-", "a-b-c"]}]}, + {"Fn::Select": [1, {"Fn::Split": ["-", "a-b-c"]}]}, + ], + ] + } + result = orchestrator.resolve_value(value) + assert result == "a,b" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_split.py b/tests/unit/lib/cfn_language_extensions/test_fn_split.py new file mode 100644 index 00000000000..bce07cafb0b --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_split.py @@ -0,0 +1,480 @@ +""" +Unit tests for the FnSplitResolver class. + +Tests cover: +- Basic Fn::Split functionality with literal strings +- Nested intrinsic function resolution +- Error handling for invalid inputs +- Integration with IntrinsicResolver orchestrator + +Requirements: + - 10.4: WHEN Fn::Split is applied to a string with a delimiter, THEN THE + Resolver SHALL return a list of strings split by the delimiter +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_split import FnSplitResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_sub import FnSubResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnSplitResolverCanResolve: + """Tests for FnSplitResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSplitResolver: + """Create a FnSplitResolver for testing.""" + return FnSplitResolver(context, None) + + def test_can_resolve_fn_split(self, resolver: FnSplitResolver): + """Test that can_resolve returns True for Fn::Split.""" + value = {"Fn::Split": [",", "a,b,c"]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnSplitResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnSplitResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnSplitResolver): + """Test that FUNCTION_NAMES contains Fn::Split.""" + assert FnSplitResolver.FUNCTION_NAMES == ["Fn::Split"] + + +class TestFnSplitResolverBasicFunctionality: + """Tests for basic Fn::Split functionality. + + Requirement 10.4: WHEN Fn::Split is applied to a string with a delimiter, + THEN THE Resolver SHALL return a list of strings split by the delimiter + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSplitResolver: + """Create a FnSplitResolver for testing.""" + return FnSplitResolver(context, None) + + def test_split_with_comma_delimiter(self, resolver: FnSplitResolver): + """Test Fn::Split with comma delimiter. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", "a,b,c"]} + assert resolver.resolve(value) == ["a", "b", "c"] + + def test_split_with_hyphen_delimiter(self, resolver: FnSplitResolver): + """Test Fn::Split with hyphen delimiter. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": ["-", "2023-01-15"]} + assert resolver.resolve(value) == ["2023", "01", "15"] + + def test_split_with_empty_delimiter(self, resolver: FnSplitResolver): + """Test Fn::Split with empty delimiter raises error. + + Kotlin implementation raises error for empty delimiter. + """ + value = {"Fn::Split": ["", "abc"]} + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + assert "delimiter cannot be empty" in str(exc_info.value) + + def test_split_with_space_delimiter(self, resolver: FnSplitResolver): + """Test Fn::Split with space delimiter. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [" ", "Hello World"]} + assert resolver.resolve(value) == ["Hello", "World"] + + def test_split_with_multi_char_delimiter(self, resolver: FnSplitResolver): + """Test Fn::Split with multi-character delimiter. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [" :: ", "a :: b :: c"]} + assert resolver.resolve(value) == ["a", "b", "c"] + + def test_split_empty_string(self, resolver: FnSplitResolver): + """Test Fn::Split with empty string returns list with empty string. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", ""]} + assert resolver.resolve(value) == [""] + + def test_split_no_delimiter_found(self, resolver: FnSplitResolver): + """Test Fn::Split when delimiter not found returns single element list. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", "no-commas-here"]} + assert resolver.resolve(value) == ["no-commas-here"] + + def test_split_consecutive_delimiters(self, resolver: FnSplitResolver): + """Test Fn::Split with consecutive delimiters creates empty strings. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", "a,,b"]} + assert resolver.resolve(value) == ["a", "", "b"] + + def test_split_delimiter_at_start(self, resolver: FnSplitResolver): + """Test Fn::Split with delimiter at start. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", ",a,b"]} + assert resolver.resolve(value) == ["", "a", "b"] + + def test_split_delimiter_at_end(self, resolver: FnSplitResolver): + """Test Fn::Split with delimiter at end. + + Requirement 10.4: Return list of strings split by delimiter + """ + value = {"Fn::Split": [",", "a,b,"]} + assert resolver.resolve(value) == ["a", "b", ""] + + +class TestFnSplitResolverErrorHandling: + """Tests for Fn::Split error handling.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSplitResolver: + """Create a FnSplitResolver for testing.""" + return FnSplitResolver(context, None) + + def test_non_list_args_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with non-list args raises InvalidTemplateException.""" + value = {"Fn::Split": "not-a-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_single_element_args_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with single element args raises InvalidTemplateException.""" + value = {"Fn::Split": [","]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_three_element_args_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with three element args raises InvalidTemplateException.""" + value = {"Fn::Split": [",", "a,b", "extra"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_non_string_delimiter_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with non-string delimiter raises InvalidTemplateException.""" + value = {"Fn::Split": [123, "a,b,c"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_non_string_source_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with non-string source raises InvalidTemplateException.""" + value = {"Fn::Split": [",", 123]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_list_source_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with list source raises InvalidTemplateException.""" + value = {"Fn::Split": [",", ["a", "b", "c"]]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_none_args_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with None args raises InvalidTemplateException.""" + value = {"Fn::Split": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + def test_dict_args_raises_exception(self, resolver: FnSplitResolver): + """Test Fn::Split with dict args raises InvalidTemplateException.""" + value = {"Fn::Split": {"delimiter": ",", "string": "a,b,c"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Split layout is incorrect" in str(exc_info.value) + + +class TestFnSplitResolverNestedIntrinsics: + """Tests for Fn::Split with nested intrinsic functions.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Delimiter": ",", + "SourceString": "a,b,c", + }, + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSplitResolver and FnRefResolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSplitResolver) + orchestrator.register_resolver(FnRefResolver) + orchestrator.register_resolver(FnSubResolver) + return orchestrator + + def test_nested_ref_for_source_string(self, orchestrator: IntrinsicResolver): + """Test Fn::Split with Ref resolving to source string.""" + value = {"Fn::Split": [",", {"Ref": "SourceString"}]} + result = orchestrator.resolve_value(value) + assert result == ["a", "b", "c"] + + def test_nested_sub_for_source_string(self, orchestrator: IntrinsicResolver): + """Test Fn::Split with Fn::Sub for source string.""" + # Add parameter for Sub + orchestrator.context.parameter_values["Prefix"] = "x" + value = {"Fn::Split": [",", {"Fn::Sub": "${Prefix},y,z"}]} + result = orchestrator.resolve_value(value) + assert result == ["x", "y", "z"] + + +class TestFnSplitResolverWithOrchestrator: + """Tests for FnSplitResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSplitResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSplitResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Split through the orchestrator.""" + value = {"Fn::Split": [",", "a,b,c"]} + result = orchestrator.resolve_value(value) + assert result == ["a", "b", "c"] + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Split in a nested template structure.""" + value = { + "Resources": { + "MyResource": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + "Properties": {"Tags": {"Fn::Split": [",", "tag1,tag2,tag3"]}}, + } + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyResource"]["Properties"]["Tags"] == ["tag1", "tag2", "tag3"] + + def test_resolve_multiple_fn_split(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Split in same structure.""" + value = { + "first": {"Fn::Split": [",", "a,b"]}, + "second": {"Fn::Split": ["-", "x-y-z"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "first": ["a", "b"], + "second": ["x", "y", "z"], + } + + def test_fn_split_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Split inside a list.""" + value = [ + {"Fn::Split": [",", "a"]}, + {"Fn::Split": [",", "a,b"]}, + {"Fn::Split": [",", "a,b,c"]}, + ] + result = orchestrator.resolve_value(value) + + assert result == [["a"], ["a", "b"], ["a", "b", "c"]] + + +class TestFnSplitResolverPartialMode: + """Tests for FnSplitResolver in partial resolution mode.""" + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnSplitResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnSplitResolver) + return orchestrator + + def test_fn_split_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::Split is resolved even in partial mode.""" + value = {"Fn::Split": [",", "a,b,c"]} + result = orchestrator.resolve_value(value) + + assert result == ["a", "b", "c"] + + def test_fn_split_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver): + """Test Fn::Split alongside preserved intrinsics in partial mode.""" + value = { + "split": {"Fn::Split": ["-", "a-b"]}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "split": ["a", "b"], + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + +class TestFnSplitResolverRealWorldExamples: + """Tests for Fn::Split with real-world CloudFormation patterns.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSplitResolver: + """Create a FnSplitResolver for testing.""" + return FnSplitResolver(context, None) + + def test_split_arn(self, resolver: FnSplitResolver): + """Test splitting an ARN.""" + value = {"Fn::Split": [":", "arn:aws:s3:::my-bucket"]} + assert resolver.resolve(value) == ["arn", "aws", "s3", "", "", "my-bucket"] + + def test_split_comma_delimited_list(self, resolver: FnSplitResolver): + """Test splitting a comma-delimited list (common parameter type).""" + value = {"Fn::Split": [",", "subnet-123,subnet-456,subnet-789"]} + assert resolver.resolve(value) == ["subnet-123", "subnet-456", "subnet-789"] + + def test_split_path(self, resolver: FnSplitResolver): + """Test splitting a path.""" + value = {"Fn::Split": ["/", "/var/log/app"]} + assert resolver.resolve(value) == ["", "var", "log", "app"] + + def test_split_cidr(self, resolver: FnSplitResolver): + """Test splitting a CIDR block.""" + value = {"Fn::Split": [".", "10.0.0.0"]} + assert resolver.resolve(value) == ["10", "0", "0", "0"] + + def test_split_availability_zones(self, resolver: FnSplitResolver): + """Test splitting availability zones.""" + value = {"Fn::Split": [",", "us-east-1a,us-east-1b,us-east-1c"]} + assert resolver.resolve(value) == ["us-east-1a", "us-east-1b", "us-east-1c"] + + +class TestFnJoinAndSplitIntegration: + """Tests for Fn::Join and Fn::Split working together.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with both resolvers.""" + from samcli.lib.cfn_language_extensions.resolvers.fn_join import FnJoinResolver + + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSplitResolver) + orchestrator.register_resolver(FnJoinResolver) + return orchestrator + + def test_split_then_join_with_different_delimiter(self, orchestrator: IntrinsicResolver): + """Test splitting and then joining with a different delimiter.""" + # Split by comma, join by hyphen + value = {"Fn::Join": ["-", {"Fn::Split": [",", "a,b,c"]}]} + result = orchestrator.resolve_value(value) + assert result == "a-b-c" + + def test_join_then_split_roundtrip(self, orchestrator: IntrinsicResolver): + """Test joining and then splitting returns original list.""" + # Join with comma, split by comma + value = {"Fn::Split": [",", {"Fn::Join": [",", ["a", "b", "c"]]}]} + result = orchestrator.resolve_value(value) + assert result == ["a", "b", "c"] + + +class TestFnSplitResolverMultiKeyDictSource: + """Tests for Fn::Split with multi-key dict source string.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSplitResolver: + return FnSplitResolver(context, None) + + def test_multi_key_dict_source_raises_exception(self, resolver: FnSplitResolver): + """Test that multi-key dict source string raises InvalidTemplateException.""" + value = {"Fn::Split": [",", {"key1": "val1", "key2": "val2"}]} + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + assert "Fn::Split layout is incorrect" in str(exc_info.value) diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_sub.py b/tests/unit/lib/cfn_language_extensions/test_fn_sub.py new file mode 100644 index 00000000000..7e202861c57 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_sub.py @@ -0,0 +1,774 @@ +""" +Unit tests for the FnSubResolver class. + +Tests cover: +- Short form string substitution +- Long form with variable map +- Parameter and pseudo-parameter substitution +- Resource attribute preservation in partial mode +- Error handling for invalid inputs + +Requirements: + - 10.2: WHEN Fn::Sub is applied to a string with ${} placeholders, THEN THE + Resolver SHALL substitute the placeholders with the corresponding values + from parameters, pseudo-parameters, or the variable map +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, + PseudoParameterValues, +) +from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_sub import FnSubResolver, PLACEHOLDER_PATTERN +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestFnSubResolverCanResolve: + """Tests for FnSubResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver for testing.""" + return FnSubResolver(context, None) + + def test_can_resolve_fn_sub(self, resolver: FnSubResolver): + """Test that can_resolve returns True for Fn::Sub.""" + value = {"Fn::Sub": "Hello ${Name}"} + assert resolver.can_resolve(value) is True + + def test_can_resolve_fn_sub_long_form(self, resolver: FnSubResolver): + """Test that can_resolve returns True for Fn::Sub long form.""" + value = {"Fn::Sub": ["Hello ${Name}", {"Name": "World"}]} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnSubResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnSubResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnSubResolver): + """Test that FUNCTION_NAMES contains Fn::Sub.""" + assert FnSubResolver.FUNCTION_NAMES == ["Fn::Sub"] + + +class TestFnSubResolverShortForm: + """Tests for Fn::Sub short form (string only). + + Requirement 10.2: WHEN Fn::Sub is applied to a string with ${} placeholders, + THEN THE Resolver SHALL substitute the placeholders with the corresponding + values from parameters, pseudo-parameters, or the variable map + """ + + @pytest.fixture + def context_with_params(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + "BucketName": "my-bucket", + "MaxCount": 10, + }, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + stack_name="my-stack", + ), + ) + + @pytest.fixture + def resolver(self, context_with_params: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver with parameter context.""" + return FnSubResolver(context_with_params, None) + + def test_substitute_single_parameter(self, resolver: FnSubResolver): + """Test substituting a single parameter. + + Requirement 10.2: Substitute placeholders with parameter values + """ + value = {"Fn::Sub": "Environment is ${Environment}"} + result = resolver.resolve(value) + assert result == "Environment is production" + + def test_substitute_multiple_parameters(self, resolver: FnSubResolver): + """Test substituting multiple parameters. + + Requirement 10.2: Substitute placeholders with parameter values + """ + value = {"Fn::Sub": "Bucket ${BucketName} in ${Environment}"} + result = resolver.resolve(value) + assert result == "Bucket my-bucket in production" + + def test_substitute_pseudo_parameter(self, resolver: FnSubResolver): + """Test substituting pseudo-parameters. + + Requirement 10.2: Substitute placeholders with pseudo-parameter values + """ + value = {"Fn::Sub": "Region is ${AWS::Region}"} + result = resolver.resolve(value) + assert result == "Region is us-west-2" + + def test_substitute_multiple_pseudo_parameters(self, resolver: FnSubResolver): + """Test substituting multiple pseudo-parameters. + + Requirement 10.2: Substitute placeholders with pseudo-parameter values + """ + value = {"Fn::Sub": "arn:aws:s3:::${AWS::AccountId}-${AWS::Region}-bucket"} + result = resolver.resolve(value) + assert result == "arn:aws:s3:::123456789012-us-west-2-bucket" + + def test_substitute_mixed_params_and_pseudo_params(self, resolver: FnSubResolver): + """Test substituting both parameters and pseudo-parameters. + + Requirement 10.2: Substitute placeholders with values + """ + value = {"Fn::Sub": "${BucketName}-${AWS::Region}-${Environment}"} + result = resolver.resolve(value) + assert result == "my-bucket-us-west-2-production" + + def test_substitute_integer_parameter(self, resolver: FnSubResolver): + """Test substituting an integer parameter. + + Requirement 10.2: Substitute placeholders with parameter values + """ + value = {"Fn::Sub": "Max count is ${MaxCount}"} + result = resolver.resolve(value) + assert result == "Max count is 10" + + def test_no_placeholders(self, resolver: FnSubResolver): + """Test string with no placeholders.""" + value = {"Fn::Sub": "Hello World"} + result = resolver.resolve(value) + assert result == "Hello World" + + def test_empty_string(self, resolver: FnSubResolver): + """Test empty string.""" + value = {"Fn::Sub": ""} + result = resolver.resolve(value) + assert result == "" + + +class TestFnSubResolverLongForm: + """Tests for Fn::Sub long form (with variable map). + + Requirement 10.2: WHEN Fn::Sub is applied to a string with ${} placeholders, + THEN THE Resolver SHALL substitute the placeholders with the corresponding + values from parameters, pseudo-parameters, or the variable map + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameter values.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + }, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + ), + ) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver with context.""" + return FnSubResolver(context, None) + + def test_substitute_from_variable_map(self, resolver: FnSubResolver): + """Test substituting from variable map. + + Requirement 10.2: Substitute placeholders with variable map values + """ + value = {"Fn::Sub": ["Hello ${Name}", {"Name": "World"}]} + result = resolver.resolve(value) + assert result == "Hello World" + + def test_variable_map_overrides_parameter(self, resolver: FnSubResolver): + """Test that variable map takes precedence over parameters. + + Requirement 10.2: Variable map should be checked first + """ + value = {"Fn::Sub": ["Env is ${Environment}", {"Environment": "staging"}]} + result = resolver.resolve(value) + assert result == "Env is staging" + + def test_fallback_to_parameter_when_not_in_map(self, resolver: FnSubResolver): + """Test fallback to parameters when not in variable map. + + Requirement 10.2: Fall back to parameters if not in variable map + """ + value = {"Fn::Sub": ["${Name} in ${Environment}", {"Name": "App"}]} + result = resolver.resolve(value) + assert result == "App in production" + + def test_fallback_to_pseudo_parameter(self, resolver: FnSubResolver): + """Test fallback to pseudo-parameters. + + Requirement 10.2: Fall back to pseudo-parameters + """ + value = {"Fn::Sub": ["${Name} in ${AWS::Region}", {"Name": "App"}]} + result = resolver.resolve(value) + assert result == "App in us-west-2" + + def test_multiple_variables_from_map(self, resolver: FnSubResolver): + """Test multiple variables from variable map. + + Requirement 10.2: Substitute multiple placeholders + """ + value = {"Fn::Sub": ["${Greeting} ${Name}!", {"Greeting": "Hello", "Name": "World"}]} + result = resolver.resolve(value) + assert result == "Hello World!" + + def test_empty_variable_map(self, resolver: FnSubResolver): + """Test with empty variable map.""" + value = {"Fn::Sub": ["Region is ${AWS::Region}", {}]} + result = resolver.resolve(value) + assert result == "Region is us-west-2" + + +class TestFnSubResolverResourceAttributes: + """Tests for Fn::Sub with resource attribute references. + + Resource attributes (e.g., ${MyResource.Arn}) cannot be resolved locally + and should be preserved in partial mode. + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context in partial mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parameter_values={"BucketName": "my-bucket"}, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + ), + ) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver in partial mode.""" + return FnSubResolver(context, None) + + def test_preserve_resource_attribute(self, resolver: FnSubResolver): + """Test that resource attributes are preserved.""" + value = {"Fn::Sub": "Bucket ARN is ${MyBucket.Arn}"} + result = resolver.resolve(value) + assert result == "Bucket ARN is ${MyBucket.Arn}" + + def test_preserve_resource_attribute_with_resolved_params(self, resolver: FnSubResolver): + """Test mixed resolved params and preserved resource attributes.""" + value = {"Fn::Sub": "${BucketName} has ARN ${MyBucket.Arn}"} + result = resolver.resolve(value) + assert result == "my-bucket has ARN ${MyBucket.Arn}" + + def test_resource_attribute_in_variable_map(self, resolver: FnSubResolver): + """Test that resource attribute in variable map is used.""" + value = {"Fn::Sub": ["ARN is ${BucketArn}", {"BucketArn": "arn:aws:s3:::my-bucket"}]} + result = resolver.resolve(value) + assert result == "ARN is arn:aws:s3:::my-bucket" + + def test_preserve_unresolved_resource_reference(self, resolver: FnSubResolver): + """Test that unresolved resource references are preserved.""" + value = {"Fn::Sub": "Resource ID is ${MyResource}"} + result = resolver.resolve(value) + assert result == "Resource ID is ${MyResource}" + + +class TestFnSubResolverEscapeSyntax: + """Tests for Fn::Sub escape syntax (${!Literal}).""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal context.""" + return TemplateProcessingContext(fragment={"Resources": {}}, parameter_values={"Name": "World"}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver.""" + return FnSubResolver(context, None) + + def test_escape_literal(self, resolver: FnSubResolver): + """Test escape syntax produces literal ${...}.""" + value = {"Fn::Sub": "Use ${!Literal} for literal"} + result = resolver.resolve(value) + assert result == "Use ${Literal} for literal" + + def test_escape_with_substitution(self, resolver: FnSubResolver): + """Test escape syntax alongside substitution.""" + value = {"Fn::Sub": "Hello ${Name}, use ${!Variable} syntax"} + result = resolver.resolve(value) + assert result == "Hello World, use ${Variable} syntax" + + +class TestFnSubResolverPseudoParameters: + """Tests for Fn::Sub with pseudo-parameters.""" + + @pytest.fixture + def context_with_pseudo_params(self) -> TemplateProcessingContext: + """Create a context with all pseudo-parameters.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + stack_id="arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid", + stack_name="my-stack", + notification_arns=["arn:aws:sns:us-west-2:123456789012:topic"], + ), + ) + + @pytest.fixture + def resolver(self, context_with_pseudo_params: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver with pseudo-parameters.""" + return FnSubResolver(context_with_pseudo_params, None) + + def test_substitute_aws_region(self, resolver: FnSubResolver): + """Test substituting AWS::Region.""" + value = {"Fn::Sub": "Region: ${AWS::Region}"} + result = resolver.resolve(value) + assert result == "Region: us-west-2" + + def test_substitute_aws_account_id(self, resolver: FnSubResolver): + """Test substituting AWS::AccountId.""" + value = {"Fn::Sub": "Account: ${AWS::AccountId}"} + result = resolver.resolve(value) + assert result == "Account: 123456789012" + + def test_substitute_aws_stack_name(self, resolver: FnSubResolver): + """Test substituting AWS::StackName.""" + value = {"Fn::Sub": "Stack: ${AWS::StackName}"} + result = resolver.resolve(value) + assert result == "Stack: my-stack" + + def test_substitute_aws_stack_id(self, resolver: FnSubResolver): + """Test substituting AWS::StackId.""" + value = {"Fn::Sub": "StackId: ${AWS::StackId}"} + result = resolver.resolve(value) + assert result == "StackId: arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid" + + def test_substitute_aws_partition(self, resolver: FnSubResolver): + """Test substituting AWS::Partition (derived from region).""" + value = {"Fn::Sub": "Partition: ${AWS::Partition}"} + result = resolver.resolve(value) + assert result == "Partition: aws" + + def test_substitute_aws_url_suffix(self, resolver: FnSubResolver): + """Test substituting AWS::URLSuffix (derived from region).""" + value = {"Fn::Sub": "Suffix: ${AWS::URLSuffix}"} + result = resolver.resolve(value) + assert result == "Suffix: amazonaws.com" + + def test_build_arn_with_pseudo_params(self, resolver: FnSubResolver): + """Test building an ARN with pseudo-parameters.""" + value = {"Fn::Sub": "arn:${AWS::Partition}:s3:::${AWS::AccountId}-${AWS::Region}-bucket"} + result = resolver.resolve(value) + assert result == "arn:aws:s3:::123456789012-us-west-2-bucket" + + +class TestFnSubResolverPartitionDerivation: + """Tests for AWS::Partition and AWS::URLSuffix derivation in Fn::Sub.""" + + def test_partition_for_china_region(self): + """Test partition derivation for China region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="cn-north-1", + account_id="123456789012", + ), + ) + resolver = FnSubResolver(context, None) + + result = resolver.resolve({"Fn::Sub": "${AWS::Partition}"}) + assert result == "aws-cn" + + def test_partition_for_govcloud_region(self): + """Test partition derivation for GovCloud region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="us-gov-west-1", + account_id="123456789012", + ), + ) + resolver = FnSubResolver(context, None) + + result = resolver.resolve({"Fn::Sub": "${AWS::Partition}"}) + assert result == "aws-us-gov" + + def test_url_suffix_for_china_region(self): + """Test URL suffix derivation for China region.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + pseudo_parameters=PseudoParameterValues( + region="cn-northwest-1", + account_id="123456789012", + ), + ) + resolver = FnSubResolver(context, None) + + result = resolver.resolve({"Fn::Sub": "${AWS::URLSuffix}"}) + assert result == "amazonaws.com.cn" + + +class TestFnSubResolverPreserveUnresolved: + """Tests for preserving unresolved placeholders.""" + + @pytest.fixture + def context_no_pseudo_params(self) -> TemplateProcessingContext: + """Create a context without pseudo-parameters.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + + @pytest.fixture + def resolver(self, context_no_pseudo_params: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver without pseudo-parameters.""" + return FnSubResolver(context_no_pseudo_params, None) + + def test_preserve_pseudo_param_without_value(self, resolver: FnSubResolver): + """Test that pseudo-parameters without values are preserved.""" + value = {"Fn::Sub": "Region: ${AWS::Region}"} + result = resolver.resolve(value) + assert result == "Region: ${AWS::Region}" + + def test_preserve_unknown_variable(self, resolver: FnSubResolver): + """Test that unknown variables are preserved.""" + value = {"Fn::Sub": "Value: ${UnknownVar}"} + result = resolver.resolve(value) + assert result == "Value: ${UnknownVar}" + + +class TestFnSubResolverErrorHandling: + """Tests for FnSubResolver error handling.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver.""" + return FnSubResolver(context, None) + + def test_invalid_type_raises_exception(self, resolver: FnSubResolver): + """Test that non-string/list argument raises exception.""" + value = {"Fn::Sub": 123} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_dict_argument_raises_exception(self, resolver: FnSubResolver): + """Test that dict argument raises exception.""" + value = {"Fn::Sub": {"key": "value"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_list_wrong_length_raises_exception(self, resolver: FnSubResolver): + """Test that list with wrong length raises exception.""" + value = {"Fn::Sub": ["only one element"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_list_three_elements_raises_exception(self, resolver: FnSubResolver): + """Test that list with three elements raises exception.""" + value = {"Fn::Sub": ["template", {"var": "val"}, "extra"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_list_non_string_template_raises_exception(self, resolver: FnSubResolver): + """Test that non-string template in list raises exception.""" + value = {"Fn::Sub": [123, {"var": "val"}]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_list_non_dict_variable_map_raises_exception(self, resolver: FnSubResolver): + """Test that non-dict variable map raises exception.""" + value = {"Fn::Sub": ["template", "not a dict"]} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + def test_none_argument_raises_exception(self, resolver: FnSubResolver): + """Test that None argument raises exception.""" + value = {"Fn::Sub": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::Sub layout is incorrect" in str(exc_info.value) + + +class TestFnSubResolverWithOrchestrator: + """Tests for FnSubResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameters and pseudo-parameters.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + "BucketName": "my-bucket", + }, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + ), + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSubResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSubResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Sub through the orchestrator.""" + value = {"Fn::Sub": "Env: ${Environment}"} + result = orchestrator.resolve_value(value) + assert result == "Env: production" + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::Sub in a nested template structure.""" + value = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::Sub": "${BucketName}-${AWS::Region}"}}, + } + } + } + result = orchestrator.resolve_value(value) + + assert result["Resources"]["MyBucket"]["Properties"]["BucketName"] == "my-bucket-us-west-2" + + def test_resolve_multiple_fn_sub(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::Sub in same structure.""" + value = { + "name": {"Fn::Sub": "${BucketName}"}, + "region": {"Fn::Sub": "${AWS::Region}"}, + "combined": {"Fn::Sub": "${BucketName}-${AWS::Region}"}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "name": "my-bucket", + "region": "us-west-2", + "combined": "my-bucket-us-west-2", + } + + def test_fn_sub_in_list(self, orchestrator: IntrinsicResolver): + """Test Fn::Sub inside a list.""" + value = [ + {"Fn::Sub": "${Environment}"}, + {"Fn::Sub": "${AWS::Region}"}, + {"Fn::Sub": "${BucketName}"}, + ] + result = orchestrator.resolve_value(value) + + assert result == ["production", "us-west-2", "my-bucket"] + + +class TestFnSubResolverNestedIntrinsics: + """Tests for Fn::Sub with nested intrinsic functions in variable map.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with parameters.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "Environment": "production", + "Prefix": "app", + }, + pseudo_parameters=PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + ), + ) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnSubResolver and FnRefResolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnSubResolver) + orchestrator.register_resolver(FnRefResolver) + return orchestrator + + def test_nested_ref_in_variable_map(self, orchestrator: IntrinsicResolver): + """Test Fn::Sub with Ref in variable map.""" + value = {"Fn::Sub": ["Name is ${Name}", {"Name": {"Ref": "Environment"}}]} + result = orchestrator.resolve_value(value) + assert result == "Name is production" + + def test_multiple_nested_refs_in_variable_map(self, orchestrator: IntrinsicResolver): + """Test Fn::Sub with multiple Refs in variable map.""" + value = {"Fn::Sub": ["${Prefix}-${Env}", {"Prefix": {"Ref": "Prefix"}, "Env": {"Ref": "Environment"}}]} + result = orchestrator.resolve_value(value) + assert result == "app-production" + + +class TestFnSubResolverValueConversion: + """Tests for value-to-string conversion in Fn::Sub.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a context with various parameter types.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={ + "StringParam": "hello", + "IntParam": 42, + "FloatParam": 3.14, + "BoolTrue": True, + "BoolFalse": False, + "ListParam": ["a", "b", "c"], + }, + ) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + """Create a FnSubResolver.""" + return FnSubResolver(context, None) + + def test_string_value(self, resolver: FnSubResolver): + """Test string value substitution.""" + value = {"Fn::Sub": "Value: ${StringParam}"} + result = resolver.resolve(value) + assert result == "Value: hello" + + def test_integer_value(self, resolver: FnSubResolver): + """Test integer value substitution.""" + value = {"Fn::Sub": "Value: ${IntParam}"} + result = resolver.resolve(value) + assert result == "Value: 42" + + def test_float_value(self, resolver: FnSubResolver): + """Test float value substitution.""" + value = {"Fn::Sub": "Value: ${FloatParam}"} + result = resolver.resolve(value) + assert result == "Value: 3.14" + + def test_boolean_true_value(self, resolver: FnSubResolver): + """Test boolean true value substitution.""" + value = {"Fn::Sub": "Value: ${BoolTrue}"} + result = resolver.resolve(value) + assert result == "Value: true" + + def test_boolean_false_value(self, resolver: FnSubResolver): + """Test boolean false value substitution.""" + value = {"Fn::Sub": "Value: ${BoolFalse}"} + result = resolver.resolve(value) + assert result == "Value: false" + + def test_list_value(self, resolver: FnSubResolver): + """Test list value substitution (joined with comma).""" + value = {"Fn::Sub": "Value: ${ListParam}"} + result = resolver.resolve(value) + assert result == "Value: a,b,c" + + +class TestPlaceholderPattern: + """Tests for the PLACEHOLDER_PATTERN regex.""" + + def test_simple_placeholder(self): + """Test matching simple placeholder.""" + match = PLACEHOLDER_PATTERN.search("${Name}") + assert match is not None + assert match.group(1) == "Name" + + def test_pseudo_parameter_placeholder(self): + """Test matching pseudo-parameter placeholder.""" + match = PLACEHOLDER_PATTERN.search("${AWS::Region}") + assert match is not None + assert match.group(1) == "AWS::Region" + + def test_resource_attribute_placeholder(self): + """Test matching resource attribute placeholder.""" + match = PLACEHOLDER_PATTERN.search("${MyBucket.Arn}") + assert match is not None + assert match.group(1) == "MyBucket.Arn" + + def test_escape_placeholder(self): + """Test matching escape placeholder.""" + match = PLACEHOLDER_PATTERN.search("${!Literal}") + assert match is not None + assert match.group(1) == "!Literal" + + def test_multiple_placeholders(self): + """Test finding all placeholders.""" + matches = PLACEHOLDER_PATTERN.findall("${A} and ${B} and ${C}") + assert matches == ["A", "B", "C"] + + def test_no_placeholder(self): + """Test no match for non-placeholder.""" + match = PLACEHOLDER_PATTERN.search("Hello World") + assert match is None + + +class TestFnSubResolverGetAttStyleInVariableMap: + """Tests for Fn::Sub with GetAtt-style references in variable map.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + return TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnSubResolver: + return FnSubResolver(context, None) + + def test_getatt_style_ref_in_variable_map(self, resolver: FnSubResolver): + """Test Fn::Sub with GetAtt-style reference provided in variable map.""" + value = { + "Fn::Sub": [ + "ARN is ${MyResource.Arn}", + {"MyResource.Arn": "arn:aws:s3:::my-bucket"}, + ] + } + result = resolver.resolve(value) + assert result == "ARN is arn:aws:s3:::my-bucket" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_to_json_string.py b/tests/unit/lib/cfn_language_extensions/test_fn_to_json_string.py new file mode 100644 index 00000000000..0f831b02850 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_fn_to_json_string.py @@ -0,0 +1,913 @@ +""" +Unit tests and property-based tests for the FnToJsonStringResolver class. + +Tests cover: +- Basic Fn::ToJsonString functionality with literal dicts and lists +- Nested intrinsic function resolution +- Error handling for invalid inputs +- Integration with IntrinsicResolver orchestrator +- Property-based tests for universal correctness properties + +Requirements: + - 4.1: WHEN Fn::ToJsonString is applied to a dictionary, THEN THE Resolver SHALL + return a JSON string representation of that dictionary + - 4.2: WHEN Fn::ToJsonString is applied to a list, THEN THE Resolver SHALL + return a JSON string representation of that list + - 4.3: WHEN Fn::ToJsonString contains nested intrinsic functions that can be + resolved, THEN THE Resolver SHALL resolve those intrinsics before + converting to JSON + - 4.4: WHEN Fn::ToJsonString contains intrinsic functions that cannot be resolved + (e.g., Fn::GetAtt), THEN THE Resolver SHALL preserve those intrinsics + in the JSON output + - 4.5: WHEN Fn::ToJsonString is applied to an invalid layout, THEN THE Resolver + SHALL raise an Invalid_Template_Exception +""" + +import json +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, +) +from samcli.lib.cfn_language_extensions.resolvers.fn_to_json_string import FnToJsonStringResolver +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +# ============================================================================= +# Unit Tests for FnToJsonStringResolver +# ============================================================================= + + +class TestFnToJsonStringResolverCanResolve: + """Tests for FnToJsonStringResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnToJsonStringResolver: + """Create a FnToJsonStringResolver for testing.""" + return FnToJsonStringResolver(context, None) + + def test_can_resolve_fn_to_json_string(self, resolver: FnToJsonStringResolver): + """Test that can_resolve returns True for Fn::ToJsonString.""" + value = {"Fn::ToJsonString": {"key": "value"}} + assert resolver.can_resolve(value) is True + + def test_cannot_resolve_other_functions(self, resolver: FnToJsonStringResolver): + """Test that can_resolve returns False for other functions.""" + assert resolver.can_resolve({"Fn::Sub": "hello"}) is False + assert resolver.can_resolve({"Fn::Join": [",", ["a", "b"]]}) is False + assert resolver.can_resolve({"Ref": "MyParam"}) is False + assert resolver.can_resolve({"Fn::Length": [1, 2, 3]}) is False + + def test_cannot_resolve_non_dict(self, resolver: FnToJsonStringResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_function_names_attribute(self, resolver: FnToJsonStringResolver): + """Test that FUNCTION_NAMES contains Fn::ToJsonString.""" + assert FnToJsonStringResolver.FUNCTION_NAMES == ["Fn::ToJsonString"] + + +class TestFnToJsonStringResolverBasicFunctionality: + """Tests for basic Fn::ToJsonString functionality. + + Requirement 4.1: WHEN Fn::ToJsonString is applied to a dictionary, THEN THE + Resolver SHALL return a JSON string representation of that dictionary + + Requirement 4.2: WHEN Fn::ToJsonString is applied to a list, THEN THE + Resolver SHALL return a JSON string representation of that list + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnToJsonStringResolver: + """Create a FnToJsonStringResolver for testing.""" + return FnToJsonStringResolver(context, None) + + def test_empty_dict_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with empty dict returns '{}'. + + Requirement 4.1: Return JSON string representation of dictionary + """ + value: Dict[str, Any] = {"Fn::ToJsonString": {}} + result = resolver.resolve(value) + assert result == "{}" + assert json.loads(result) == {} + + def test_simple_dict_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with simple dict. + + Requirement 4.1: Return JSON string representation of dictionary + """ + value = {"Fn::ToJsonString": {"key": "value"}} + result = resolver.resolve(value) + assert json.loads(result) == {"key": "value"} + + def test_nested_dict_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with nested dict. + + Requirement 4.1: Return JSON string representation of dictionary + """ + value = {"Fn::ToJsonString": {"outer": {"inner": "value"}}} + result = resolver.resolve(value) + assert json.loads(result) == {"outer": {"inner": "value"}} + + def test_empty_list_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with empty list returns '[]'. + + Requirement 4.2: Return JSON string representation of list + """ + value: Dict[str, Any] = {"Fn::ToJsonString": []} + result = resolver.resolve(value) + assert result == "[]" + assert json.loads(result) == [] + + def test_simple_list_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with simple list. + + Requirement 4.2: Return JSON string representation of list + """ + value = {"Fn::ToJsonString": [1, 2, 3]} + result = resolver.resolve(value) + assert json.loads(result) == [1, 2, 3] + + def test_mixed_type_list_to_json_string(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with mixed type list. + + Requirement 4.2: Return JSON string representation of list + """ + value = {"Fn::ToJsonString": [1, "two", {"three": 3}, [4], None, True]} + result = resolver.resolve(value) + assert json.loads(result) == [1, "two", {"three": 3}, [4], None, True] + + def test_compact_json_output(self, resolver: FnToJsonStringResolver): + """Test that Fn::ToJsonString produces compact JSON (no extra whitespace). + + Requirement 4.1, 4.2: Return JSON string representation + """ + value = {"Fn::ToJsonString": {"key": "value"}} + result = resolver.resolve(value) + # Should use compact separators (no spaces after : or ,) + assert result == '{"key":"value"}' + + +class TestFnToJsonStringResolverErrorHandling: + """Tests for Fn::ToJsonString error handling. + + Requirement 4.5: WHEN Fn::ToJsonString is applied to an invalid layout, THEN THE + Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> FnToJsonStringResolver: + """Create a FnToJsonStringResolver for testing.""" + return FnToJsonStringResolver(context, None) + + def test_non_dict_or_list_string_raises_exception(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with string raises InvalidTemplateException. + + Requirement 4.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::ToJsonString": "not-a-dict-or-list"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::ToJsonString layout is incorrect" in str(exc_info.value) + + def test_non_dict_or_list_integer_raises_exception(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with integer raises InvalidTemplateException. + + Requirement 4.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::ToJsonString": 42} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::ToJsonString layout is incorrect" in str(exc_info.value) + + def test_non_dict_or_list_none_raises_exception(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with None raises InvalidTemplateException. + + Requirement 4.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::ToJsonString": None} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::ToJsonString layout is incorrect" in str(exc_info.value) + + def test_non_dict_or_list_boolean_raises_exception(self, resolver: FnToJsonStringResolver): + """Test Fn::ToJsonString with boolean raises InvalidTemplateException. + + Requirement 4.5: Raise Invalid_Template_Exception for invalid layout + """ + value = {"Fn::ToJsonString": True} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + assert "Fn::ToJsonString layout is incorrect" in str(exc_info.value) + + def test_error_message_exact_format(self, resolver: FnToJsonStringResolver): + """Test that error message matches exact expected format. + + Requirement 4.5: Raise Invalid_Template_Exception indicating incorrect layout + """ + value = {"Fn::ToJsonString": "not-valid"} + + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + + # Verify exact error message format + assert str(exc_info.value) == "Fn::ToJsonString layout is incorrect" + + +class MockDictResolver(IntrinsicFunctionResolver): + """A mock resolver that returns a dict for testing nested resolution.""" + + FUNCTION_NAMES = ["Fn::MockDict"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return a dict based on the argument.""" + args = self.get_function_args(value) + if isinstance(args, str): + return {"resolved": args} + return args + + +class MockListResolver(IntrinsicFunctionResolver): + """A mock resolver that returns a list for testing nested resolution.""" + + FUNCTION_NAMES = ["Fn::MockList"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return a list based on the argument.""" + args = self.get_function_args(value) + if isinstance(args, int): + return list(range(args)) + return args + + +class TestFnToJsonStringResolverNestedIntrinsics: + """Tests for Fn::ToJsonString with nested intrinsic functions. + + Requirement 4.3: WHEN Fn::ToJsonString contains nested intrinsic functions + that can be resolved, THEN THE Resolver SHALL resolve those intrinsics before + converting to JSON + """ + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnToJsonStringResolver and mock resolvers.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnToJsonStringResolver) + orchestrator.register_resolver(MockDictResolver) + orchestrator.register_resolver(MockListResolver) + return orchestrator + + def test_nested_intrinsic_resolved_first(self, orchestrator: IntrinsicResolver): + """Test that nested intrinsic is resolved before JSON conversion. + + Requirement 4.3: Resolve nested intrinsics before converting to JSON + """ + # Fn::MockDict with arg "test" returns {"resolved": "test"} + value = {"Fn::ToJsonString": {"Fn::MockDict": "test"}} + result = orchestrator.resolve_value(value) + + assert json.loads(result) == {"resolved": "test"} + + def test_nested_list_intrinsic_resolved(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic that resolves to list. + + Requirement 4.3: Resolve nested intrinsics before converting to JSON + """ + # Fn::MockList with arg 3 returns [0, 1, 2] + value = {"Fn::ToJsonString": {"Fn::MockList": 3}} + result = orchestrator.resolve_value(value) + + assert json.loads(result) == [0, 1, 2] + + def test_nested_intrinsic_in_dict_value(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic inside a dict value. + + Requirement 4.3: Resolve nested intrinsics before converting to JSON + """ + value = {"Fn::ToJsonString": {"static": "value", "dynamic": {"Fn::MockDict": "nested"}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"static": "value", "dynamic": {"resolved": "nested"}} + + def test_nested_intrinsic_in_list_element(self, orchestrator: IntrinsicResolver): + """Test nested intrinsic inside a list element. + + Requirement 4.3: Resolve nested intrinsics before converting to JSON + """ + value = {"Fn::ToJsonString": ["static", {"Fn::MockDict": "nested"}, {"Fn::MockList": 2}]} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == ["static", {"resolved": "nested"}, [0, 1]] + + +class TestFnToJsonStringResolverWithOrchestrator: + """Tests for FnToJsonStringResolver integration with IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def orchestrator(self, context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnToJsonStringResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnToJsonStringResolver) + return orchestrator + + def test_resolve_via_orchestrator(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::ToJsonString through the orchestrator.""" + value = {"Fn::ToJsonString": {"key": "value"}} + result = orchestrator.resolve_value(value) + + assert json.loads(result) == {"key": "value"} + + def test_resolve_in_nested_structure(self, orchestrator: IntrinsicResolver): + """Test resolving Fn::ToJsonString in a nested template structure.""" + value = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Environment": {"Variables": {"CONFIG": {"Fn::ToJsonString": {"setting": "value"}}}} + }, + } + } + } + result = orchestrator.resolve_value(value) + + config = result["Resources"]["MyFunction"]["Properties"]["Environment"]["Variables"]["CONFIG"] + assert json.loads(config) == {"setting": "value"} + + def test_resolve_multiple_fn_to_json_string(self, orchestrator: IntrinsicResolver): + """Test resolving multiple Fn::ToJsonString in same structure.""" + value = { + "first": {"Fn::ToJsonString": {"a": 1}}, + "second": {"Fn::ToJsonString": [1, 2, 3]}, + "third": {"Fn::ToJsonString": {}}, + } + result = orchestrator.resolve_value(value) + + assert json.loads(result["first"]) == {"a": 1} + assert json.loads(result["second"]) == [1, 2, 3] + assert json.loads(result["third"]) == {} + + +class TestFnToJsonStringResolverPartialMode: + """Tests for FnToJsonStringResolver in partial resolution mode. + + Fn::ToJsonString should always be resolved, even in partial mode. + Unresolvable intrinsics (like Fn::GetAtt) should be preserved in the JSON output. + + Requirement 4.4: WHEN Fn::ToJsonString contains intrinsic functions that cannot + be resolved (e.g., Fn::GetAtt), THEN THE Resolver SHALL preserve those intrinsics + in the JSON output + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator in partial mode with FnToJsonStringResolver.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnToJsonStringResolver) + return orchestrator + + def test_fn_to_json_string_resolved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Test that Fn::ToJsonString is resolved even in partial mode. + + Requirement 16.4: In partial mode, still resolve Fn::ToJsonString + """ + value = {"Fn::ToJsonString": {"key": "value"}} + result = orchestrator.resolve_value(value) + + assert json.loads(result) == {"key": "value"} + + def test_fn_get_att_preserved_in_json_output(self, orchestrator: IntrinsicResolver): + """Test that Fn::GetAtt is preserved in JSON output in partial mode. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + """ + value = {"Fn::ToJsonString": {"arn": {"Fn::GetAtt": ["MyBucket", "Arn"]}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"arn": {"Fn::GetAtt": ["MyBucket", "Arn"]}} + + def test_mixed_resolvable_and_unresolvable(self, orchestrator: IntrinsicResolver): + """Test mix of resolvable and unresolvable intrinsics in partial mode.""" + value = { + "Fn::ToJsonString": { + "static": "value", + "preserved": {"Fn::GetAtt": ["Resource", "Attr"]}, + "also_preserved": {"Fn::ImportValue": "ExportName"}, + } + } + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == { + "static": "value", + "preserved": {"Fn::GetAtt": ["Resource", "Attr"]}, + "also_preserved": {"Fn::ImportValue": "ExportName"}, + } + + def test_fn_import_value_preserved_in_json_output(self, orchestrator: IntrinsicResolver): + """Test that Fn::ImportValue is preserved in JSON output in partial mode. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + """ + value = {"Fn::ToJsonString": {"imported": {"Fn::ImportValue": "SharedVpcId"}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"imported": {"Fn::ImportValue": "SharedVpcId"}} + + def test_fn_get_azs_preserved_in_json_output(self, orchestrator: IntrinsicResolver): + """Test that Fn::GetAZs is preserved in JSON output in partial mode. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + Fn::GetAZs returns availability zones for a region and requires + runtime AWS information, so it must be preserved. + """ + value = {"Fn::ToJsonString": {"availabilityZones": {"Fn::GetAZs": "us-east-1"}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"availabilityZones": {"Fn::GetAZs": "us-east-1"}} + + def test_fn_get_azs_empty_region_preserved(self, orchestrator: IntrinsicResolver): + """Test that Fn::GetAZs with empty string (current region) is preserved. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + Fn::GetAZs with empty string returns AZs for the current region. + """ + value = {"Fn::ToJsonString": {"currentRegionAZs": {"Fn::GetAZs": ""}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"currentRegionAZs": {"Fn::GetAZs": ""}} + + def test_fn_cidr_preserved_in_json_output(self, orchestrator: IntrinsicResolver): + """Test that Fn::Cidr is preserved in JSON output in partial mode. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + Fn::Cidr generates CIDR address blocks and is typically preserved + for CloudFormation to resolve. + """ + value = {"Fn::ToJsonString": {"subnets": {"Fn::Cidr": ["10.0.0.0/16", 6, 8]}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"subnets": {"Fn::Cidr": ["10.0.0.0/16", 6, 8]}} + + def test_ref_to_resource_preserved_in_json_output(self, orchestrator: IntrinsicResolver): + """Test that Ref to a resource is preserved in JSON output in partial mode. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + Ref to a resource (not a parameter or pseudo-parameter) requires + the deployed resource's physical ID, so it must be preserved. + """ + value = {"Fn::ToJsonString": {"bucketName": {"Ref": "MyS3Bucket"}}} + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"bucketName": {"Ref": "MyS3Bucket"}} + + def test_all_unresolvable_intrinsics_preserved_in_list(self, orchestrator: IntrinsicResolver): + """Test that all unresolvable intrinsics are preserved in a list context. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + This test verifies that unresolvable intrinsics work correctly + when they appear as list elements rather than dict values. + """ + value = { + "Fn::ToJsonString": [ + {"Fn::GetAtt": ["MyBucket", "Arn"]}, + {"Fn::ImportValue": "SharedValue"}, + {"Fn::GetAZs": "us-west-2"}, + {"Fn::Cidr": ["10.0.0.0/8", 3, 5]}, + {"Ref": "MyResource"}, + ] + } + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == [ + {"Fn::GetAtt": ["MyBucket", "Arn"]}, + {"Fn::ImportValue": "SharedValue"}, + {"Fn::GetAZs": "us-west-2"}, + {"Fn::Cidr": ["10.0.0.0/8", 3, 5]}, + {"Ref": "MyResource"}, + ] + + def test_nested_unresolvable_intrinsics_preserved(self, orchestrator: IntrinsicResolver): + """Test that deeply nested unresolvable intrinsics are preserved. + + Requirement 4.4: Preserve unresolvable intrinsics in JSON output + + This test verifies that unresolvable intrinsics are preserved + even when they appear in deeply nested structures. + """ + value = { + "Fn::ToJsonString": {"level1": {"level2": {"level3": {"arn": {"Fn::GetAtt": ["DeepResource", "Arn"]}}}}} + } + result = orchestrator.resolve_value(value) + + parsed = json.loads(result) + assert parsed == {"level1": {"level2": {"level3": {"arn": {"Fn::GetAtt": ["DeepResource", "Arn"]}}}}} + + +# ============================================================================= +# Parametrized Tests for Fn::ToJsonString +# ============================================================================= + + +class TestFnToJsonStringParametrizedTests: + """ + Parametrized tests for Fn::ToJsonString intrinsic function. + + These tests validate that for any dictionary or list value, applying + Fn::ToJsonString and then json.loads SHALL produce a value equivalent + to the original (after resolving any nested resolvable intrinsics). + + **Validates: Requirements 4.1, 4.2, 4.3** + """ + + @staticmethod + def _create_context() -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @staticmethod + def _create_orchestrator(context: TemplateProcessingContext) -> IntrinsicResolver: + """Create an orchestrator with FnToJsonStringResolver registered.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnToJsonStringResolver) + return orchestrator + + @pytest.mark.parametrize( + "data", + [ + {"key": "value", "number": 42}, + {"empty": None, "flag": True, "count": 0}, + {"nested_key": "hello world", "pi": 3.14}, + ], + ) + def test_fn_to_json_string_round_trip_simple_dict(self, data: Dict[str, Any]): + """ + For any simple dictionary, applying Fn::ToJsonString and then json.loads + SHALL produce a value equivalent to the original. + + **Validates: Requirements 4.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed == data + + @pytest.mark.parametrize( + "data", + [ + [1, "two", 3.0, None, True], + [], + [42, -100, 0], + ], + ) + def test_fn_to_json_string_round_trip_simple_list(self, data: List[Any]): + """ + For any simple list, applying Fn::ToJsonString and then json.loads + SHALL produce a value equivalent to the original. + + **Validates: Requirements 4.2** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed == data + + @pytest.mark.parametrize( + "data", + [ + {"outer": {"inner": "value", "list": [1, 2]}}, + {"a": {"b": {"c": "deep"}}}, + {"mixed": [{"key": "val"}, [1, 2], "text"]}, + ], + ) + def test_fn_to_json_string_round_trip_nested_dict(self, data: Dict[str, Any]): + """ + For any nested dictionary, applying Fn::ToJsonString and then json.loads + SHALL produce a value equivalent to the original. + + **Validates: Requirements 4.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed == data + + @pytest.mark.parametrize( + "data", + [ + [[1, 2], [3, 4], [5]], + [{"a": 1}, {"b": 2}], + [None, [True, False], {"key": "val"}], + ], + ) + def test_fn_to_json_string_round_trip_nested_list(self, data: List[Any]): + """ + For any nested list, applying Fn::ToJsonString and then json.loads + SHALL produce a value equivalent to the original. + + **Validates: Requirements 4.2** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed == data + + @pytest.mark.parametrize( + "static_data, mock_value", + [ + ({"key": "value"}, "test"), + ({"count": 42, "flag": True}, "hello"), + ({"name": "resource"}, "world"), + ], + ) + def test_fn_to_json_string_round_trip_with_nested_intrinsic(self, static_data: Dict[str, Any], mock_value: str): + """ + For any dictionary containing nested intrinsic functions that can be resolved, + applying Fn::ToJsonString and then json.loads SHALL produce a value equivalent + to the original after resolving the nested intrinsics. + + **Validates: Requirements 4.3** + """ + context = self._create_context() + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnToJsonStringResolver) + orchestrator.register_resolver(MockDictResolver) + + input_data = dict(static_data) + input_data["dynamic"] = {"Fn::MockDict": mock_value} + + value = {"Fn::ToJsonString": input_data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + + expected = dict(static_data) + expected["dynamic"] = {"resolved": mock_value} + + parsed = json.loads(result) + assert parsed == expected + + @pytest.mark.parametrize( + "items, mock_count", + [ + ([1, "two"], 3), + ([], 0), + ([None, True, 42], 5), + ], + ) + def test_fn_to_json_string_round_trip_list_with_nested_intrinsic(self, items: List[Any], mock_count: int): + """ + For any list containing nested intrinsic functions that can be resolved, + applying Fn::ToJsonString and then json.loads SHALL produce a value equivalent + to the original after resolving the nested intrinsics. + + **Validates: Requirements 4.2, 4.3** + """ + context = self._create_context() + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(FnToJsonStringResolver) + orchestrator.register_resolver(MockListResolver) + + input_data = list(items) + input_data.append({"Fn::MockList": mock_count}) + + value = {"Fn::ToJsonString": input_data} + result = orchestrator.resolve_value(value) + + assert isinstance(result, str) + + expected = list(items) + expected.append(list(range(mock_count))) + + parsed = json.loads(result) + assert parsed == expected + + @pytest.mark.parametrize( + "data", + [ + {"setting": "value"}, + {"a": 1, "b": [2, 3]}, + {"nested": {"deep": True}}, + ], + ) + def test_fn_to_json_string_produces_valid_json(self, data: Dict[str, Any]): + """ + For any dictionary, Fn::ToJsonString SHALL produce a valid JSON string + that can be parsed without errors. + + **Validates: Requirements 4.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + try: + parsed = json.loads(result) + except json.JSONDecodeError as e: + pytest.fail(f"Fn::ToJsonString produced invalid JSON: {e}") + + assert isinstance(parsed, dict) + + @pytest.mark.parametrize( + "data", + [ + [1, 2, 3], + ["a", None, True], + [{"key": "val"}, [1, 2]], + ], + ) + def test_fn_to_json_string_list_produces_valid_json(self, data: List[Any]): + """ + For any list, Fn::ToJsonString SHALL produce a valid JSON string + that can be parsed without errors. + + **Validates: Requirements 4.2** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + try: + parsed = json.loads(result) + except json.JSONDecodeError as e: + pytest.fail(f"Fn::ToJsonString produced invalid JSON: {e}") + + assert isinstance(parsed, list) + + @pytest.mark.parametrize( + "data", + [ + {"setting": "value"}, + {"count": 42, "enabled": True}, + {"tags": [{"Key": "env", "Value": "prod"}]}, + ], + ) + def test_fn_to_json_string_in_template_structure(self, data: Dict[str, Any]): + """ + For any dictionary embedded in a CloudFormation template structure, + Fn::ToJsonString SHALL produce a valid JSON string that round-trips correctly. + + **Validates: Requirements 4.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + template_value = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": {"Environment": {"Variables": {"CONFIG": {"Fn::ToJsonString": data}}}}, + } + } + } + + result = orchestrator.resolve_value(template_value) + + config_json = result["Resources"]["MyFunction"]["Properties"]["Environment"]["Variables"]["CONFIG"] + parsed = json.loads(config_json) + assert parsed == data + + @pytest.mark.parametrize( + "data", + [ + {"key": "value"}, + {"a": 1, "b": "two"}, + {"flag": True, "count": 0}, + ], + ) + def test_fn_to_json_string_compact_output(self, data: Dict[str, Any]): + """ + For any dictionary, Fn::ToJsonString SHALL produce compact JSON output + (no unnecessary whitespace) while still being valid JSON. + + **Validates: Requirements 4.1** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + value = {"Fn::ToJsonString": data} + result = orchestrator.resolve_value(value) + + expected_compact = json.dumps(data, separators=(",", ":")) + assert result == expected_compact + + @pytest.mark.parametrize( + "data", + [ + {"key": "value", "num": 42}, + [1, "two", None], + {"nested": {"a": [1, 2]}}, + ], + ) + def test_fn_to_json_string_idempotent_round_trip(self, data: Any): + """ + For any dictionary or list, the round-trip (Fn::ToJsonString -> json.loads) + SHALL be idempotent: applying it multiple times produces the same result. + + **Validates: Requirements 4.1, 4.2** + """ + context = self._create_context() + orchestrator = self._create_orchestrator(context) + + # First round-trip + value1 = {"Fn::ToJsonString": data} + result1 = orchestrator.resolve_value(value1) + parsed1 = json.loads(result1) + + # Second round-trip (using the parsed result) + value2 = {"Fn::ToJsonString": parsed1} + result2 = orchestrator.resolve_value(value2) + parsed2 = json.loads(result2) + + assert parsed1 == parsed2 + assert result1 == result2 diff --git a/tests/unit/lib/cfn_language_extensions/test_foreach_fallback_resolution.py b/tests/unit/lib/cfn_language_extensions/test_foreach_fallback_resolution.py new file mode 100644 index 00000000000..888fcbf83ff --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_foreach_fallback_resolution.py @@ -0,0 +1,263 @@ +""" +Tests for ForEachProcessor fallback resolution paths. + +Covers _resolve_intrinsic, _resolve_collection_item, and _resolve_collection +when _intrinsic_resolver is None (fallback manual resolution). +""" + +import pytest + +from samcli.lib.cfn_language_extensions.processors.foreach import ForEachProcessor +from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext, ParsedTemplate + + +@pytest.fixture +def processor() -> ForEachProcessor: + return ForEachProcessor(intrinsic_resolver=None) + + +class TestResolveIntrinsicFallback: + """Tests for _resolve_intrinsic fallback paths (no intrinsic_resolver).""" + + def test_ref_resolved_from_parameter_values(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"MyParam": "resolved-value"}, + ) + result = processor._resolve_intrinsic({"Ref": "MyParam"}, context) + assert result == "resolved-value" + + def test_ref_resolved_from_parsed_template_parameters(self, processor): + + parsed = ParsedTemplate(parameters={"MyParam": {"Default": "default-val"}}) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=parsed, + ) + result = processor._resolve_intrinsic({"Ref": "MyParam"}, context) + assert result == "default-val" + + def test_ref_unresolvable_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Ref": "Unknown"} + result = processor._resolve_intrinsic(value, context) + assert result == value + + def test_non_ref_dict_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Fn::Sub": "something"} + result = processor._resolve_intrinsic(value, context) + assert result == value + + def test_non_dict_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + assert processor._resolve_intrinsic("plain string", context) == "plain string" + assert processor._resolve_intrinsic(42, context) == 42 + + def test_multi_key_dict_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Ref": "X", "Extra": "Y"} + result = processor._resolve_intrinsic(value, context) + assert result == value + + def test_non_string_ref_target_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Ref": 123} + result = processor._resolve_intrinsic(value, context) + assert result == value + + +class TestResolveCollectionItemFallback: + """Tests for _resolve_collection_item fallback paths.""" + + def test_list_item_returned_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + result = processor._resolve_collection_item([1, "a"], context) + assert result == [1, "a"] + + def test_ref_resolved_from_parameter_values_string(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Names": "Alpha,Beta,Gamma"}, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["Alpha", "Beta", "Gamma"] + + def test_ref_resolved_from_parameter_values_list(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Names": ["A", "B"]}, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["A", "B"] + + def test_ref_resolved_from_parsed_template_comma_delimited(self, processor): + + parsed = ParsedTemplate(parameters={"Names": {"Type": "CommaDelimitedList", "Default": "X,Y,Z"}}) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=parsed, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["X", "Y", "Z"] + + def test_ref_resolved_from_parsed_template_non_comma_delimited(self, processor): + + parsed = ParsedTemplate(parameters={"Names": {"Type": "String", "Default": ["A", "B"]}}) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=parsed, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["A", "B"] + + def test_ref_resolved_from_fragment_parameters(self, processor): + context = TemplateProcessingContext( + fragment={ + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "P,Q"}}, + "Resources": {}, + }, + parameter_values={}, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["P", "Q"] + + def test_ref_resolved_from_fragment_parameters_non_comma(self, processor): + context = TemplateProcessingContext( + fragment={ + "Parameters": {"Names": {"Type": "String", "Default": ["M", "N"]}}, + "Resources": {}, + }, + parameter_values={}, + ) + result = processor._resolve_collection_item({"Ref": "Names"}, context) + assert result == ["M", "N"] + + def test_unresolvable_ref_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Ref": "Unknown"} + result = processor._resolve_collection_item(value, context) + assert result == value + + def test_non_ref_dict_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Fn::Split": [",", "a,b"]} + result = processor._resolve_collection_item(value, context) + assert result == value + + +class TestResolveCollectionFallback: + """Tests for _resolve_collection fallback paths.""" + + def test_ref_resolved_from_parameter_values_string(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Names": "A,B,C"}, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["A", "B", "C"] + + def test_ref_resolved_from_parameter_values_list(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Names": ["X", "Y"]}, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["X", "Y"] + + def test_ref_resolved_from_parsed_template_comma_delimited(self, processor): + + parsed = ParsedTemplate(parameters={"Names": {"Type": "CommaDelimitedList", "Default": "D,E,F"}}) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=parsed, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["D", "E", "F"] + + def test_ref_resolved_from_parsed_template_non_comma(self, processor): + + parsed = ParsedTemplate(parameters={"Names": {"Type": "String", "Default": ["G", "H"]}}) + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + parsed_template=parsed, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["G", "H"] + + def test_ref_resolved_from_fragment_parameters(self, processor): + context = TemplateProcessingContext( + fragment={ + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "R,S"}}, + "Resources": {}, + }, + parameter_values={}, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["R", "S"] + + def test_ref_resolved_from_fragment_parameters_non_comma(self, processor): + context = TemplateProcessingContext( + fragment={ + "Parameters": {"Names": {"Type": "String", "Default": ["T", "U"]}}, + "Resources": {}, + }, + parameter_values={}, + ) + result = processor._resolve_collection({"Ref": "Names"}, context) + assert result == ["T", "U"] + + def test_unresolvable_ref_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Ref": "Unknown"} + result = processor._resolve_collection(value, context) + assert result == value + + def test_static_list_returned_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + result = processor._resolve_collection(["A", "B"], context) + assert result == ["A", "B"] + + def test_non_ref_dict_returns_as_is(self, processor): + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={}, + ) + value = {"Fn::Split": [",", "a,b"]} + result = processor._resolve_collection(value, context) + assert result == value diff --git a/tests/unit/lib/cfn_language_extensions/test_foreach_processor.py b/tests/unit/lib/cfn_language_extensions/test_foreach_processor.py new file mode 100644 index 00000000000..c8c4c316af7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_foreach_processor.py @@ -0,0 +1,3126 @@ +""" +Unit tests for the ForEachProcessor class. + +Tests cover: +- Detection of Fn::ForEach:: prefixed keys +- Validation of ForEach structure (identifier, collection, body) +- Resolution of collections containing intrinsic functions +- Error handling for invalid ForEach constructs + +Requirements: + - 6.7: WHEN Fn::ForEach has invalid layout (wrong number of arguments, + invalid types), THEN THE Resolver SHALL raise an Invalid_Template_Exception + - 6.8: WHEN Fn::ForEach collection contains a Ref to a parameter, + THEN THE Resolver SHALL resolve the parameter value before iteration +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.processors.foreach import ForEachProcessor +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestForEachProcessorDetection: + """Tests for ForEachProcessor Fn::ForEach:: key detection.""" + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_detects_foreach_key(self, processor: ForEachProcessor): + """Test that _is_foreach_key returns True for valid ForEach keys.""" + assert processor._is_foreach_key("Fn::ForEach::Topics") is True + assert processor._is_foreach_key("Fn::ForEach::MyLoop") is True + assert processor._is_foreach_key("Fn::ForEach::A") is True + + def test_does_not_detect_non_foreach_keys(self, processor: ForEachProcessor): + """Test that _is_foreach_key returns False for non-ForEach keys.""" + assert processor._is_foreach_key("Fn::Sub") is False + assert processor._is_foreach_key("Fn::Length") is False + assert processor._is_foreach_key("Ref") is False + assert processor._is_foreach_key("MyResource") is False + assert processor._is_foreach_key("Fn::ForEach") is False # Missing :: + + def test_does_not_detect_non_string_keys(self, processor: ForEachProcessor): + """Test that _is_foreach_key returns False for non-string values.""" + assert processor._is_foreach_key(123) is False # type: ignore[arg-type] # Intentionally testing with invalid type + assert processor._is_foreach_key(None) is False # type: ignore[arg-type] # Intentionally testing with invalid type + assert processor._is_foreach_key(["Fn::ForEach::Test"]) is False # type: ignore[arg-type] # Intentionally testing with invalid type + + def test_get_foreach_loop_name(self, processor: ForEachProcessor): + """Test extracting loop name from ForEach key.""" + assert processor.get_foreach_loop_name("Fn::ForEach::Topics") == "Topics" + assert processor.get_foreach_loop_name("Fn::ForEach::MyLoop") == "MyLoop" + assert processor.get_foreach_loop_name("Fn::ForEach::A") == "A" + + def test_get_foreach_loop_name_invalid_key_raises(self, processor: ForEachProcessor): + """Test that get_foreach_loop_name raises for invalid keys.""" + with pytest.raises(ValueError): + processor.get_foreach_loop_name("Fn::Sub") + + +class TestForEachProcessorValidation: + """Tests for ForEachProcessor validation of ForEach structure. + + Requirement 6.7: WHEN Fn::ForEach has invalid layout (wrong number of arguments, + invalid types), THEN THE Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + def test_valid_foreach_structure(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that valid ForEach structure passes validation.""" + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + # Should not raise + processor.process_template(context) + + def test_valid_foreach_with_empty_collection(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach with empty collection passes validation.""" + context.fragment = { + "Resources": {"Fn::ForEach::Topics": ["TopicName", [], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}]} + } + # Should not raise + processor.process_template(context) + + def test_invalid_foreach_not_a_list(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach value must be a list. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid layout + """ + context.fragment = {"Resources": {"Fn::ForEach::Topics": "not-a-list"}} + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_wrong_number_of_elements_too_few( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that ForEach must have exactly 3 elements. + + Requirement 6.7: Raise Invalid_Template_Exception for wrong number of arguments + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts"], + # Missing template body + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_wrong_number_of_elements_too_many( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that ForEach must have exactly 3 elements. + + Requirement 6.7: Raise Invalid_Template_Exception for wrong number of arguments + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + "extra-element", + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_identifier_not_string( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that ForEach identifier must be a string. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + 123, # Not a string + ["Alerts"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_identifier_empty_string( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that ForEach identifier must be non-empty. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "", # Empty string + ["Alerts"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_identifier_none(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach identifier cannot be None. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [None, ["Alerts"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] # None + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_collection_not_list(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach collection must be a list. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + "not-a-list", # String instead of list + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_collection_integer(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach collection cannot be an integer. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + 42, # Integer instead of list + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_body_not_dict(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach template body must be a dictionary. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": {"Fn::ForEach::Topics": ["TopicName", ["Alerts"], "not-a-dict"]} # String instead of dict + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_body_list(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach template body cannot be a list. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["Alerts"], ["not", "a", "dict"]] # List instead of dict + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + def test_invalid_foreach_body_none(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach template body cannot be None. + + Requirement 6.7: Raise Invalid_Template_Exception for invalid types + """ + context.fragment = { + "Resources": {"Fn::ForEach::Topics": ["TopicName", ["Alerts"], None]} # None instead of dict + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Fn::ForEach::Topics layout is incorrect" in str(exc_info.value) + + +class TestForEachProcessorSections: + """Tests for ForEachProcessor processing different template sections.""" + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + def test_processes_resources_section(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach in Resources section is expanded. + + Requirements: + - 6.1: Fn::ForEach in Resources SHALL expand to multiple resources + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ], + "ExistingResource": {"Type": "AWS::S3::Bucket"}, + } + } + + processor.process_template(context) + + # ForEach should be expanded into multiple resources + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + # ForEach key should be removed after expansion + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + # Existing resources should be preserved + assert "ExistingResource" in context.fragment["Resources"] + + def test_processes_outputs_section(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach in Outputs section is expanded. + + Requirements: + - 6.2: Fn::ForEach in Outputs SHALL expand to multiple outputs + """ + context.fragment = { + "Resources": {}, + "Outputs": { + "Fn::ForEach::TopicOutputs": [ + "TopicName", + ["Alerts", "Notifications"], + {"${TopicName}Arn": {"Value": {"Ref": "Topic${TopicName}"}}}, + ], + "ExistingOutput": {"Value": "test"}, + }, + } + + processor.process_template(context) + + # ForEach should be expanded into multiple outputs + assert "AlertsArn" in context.fragment["Outputs"] + assert "NotificationsArn" in context.fragment["Outputs"] + # ForEach key should be removed after expansion + assert "Fn::ForEach::TopicOutputs" not in context.fragment["Outputs"] + # Existing outputs should be preserved + assert "ExistingOutput" in context.fragment["Outputs"] + # Verify identifier substitution in values + assert context.fragment["Outputs"]["AlertsArn"]["Value"] == {"Ref": "TopicAlerts"} + assert context.fragment["Outputs"]["NotificationsArn"]["Value"] == {"Ref": "TopicNotifications"} + + def test_processes_conditions_section(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach in Conditions section is expanded. + + Requirements: + - 6.3: Fn::ForEach in Conditions SHALL expand to multiple conditions + """ + context.fragment = { + "Resources": {}, + "Conditions": { + "Fn::ForEach::EnvConditions": [ + "Env", + ["Dev", "Prod"], + {"Is${Env}": {"Fn::Equals": [{"Ref": "Environment"}, "${Env}"]}}, + ], + "ExistingCondition": {"Fn::Equals": ["a", "b"]}, + }, + } + + processor.process_template(context) + + # ForEach should be expanded into multiple conditions + assert "IsDev" in context.fragment["Conditions"] + assert "IsProd" in context.fragment["Conditions"] + # ForEach key should be removed after expansion + assert "Fn::ForEach::EnvConditions" not in context.fragment["Conditions"] + # Existing conditions should be preserved + assert "ExistingCondition" in context.fragment["Conditions"] + # Verify identifier substitution in values + assert context.fragment["Conditions"]["IsDev"]["Fn::Equals"][1] == "Dev" + assert context.fragment["Conditions"]["IsProd"]["Fn::Equals"][1] == "Prod" + + def test_processes_multiple_sections(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that ForEach in multiple sections is expanded.""" + context.fragment = { + "Conditions": { + "Fn::ForEach::EnvConditions": [ + "Env", + ["Dev", "Prod"], + {"Is${Env}": {"Fn::Equals": [{"Ref": "Environment"}, "${Env}"]}}, + ] + }, + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["Alerts"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + }, + "Outputs": { + "Fn::ForEach::TopicOutputs": [ + "TopicName", + ["Alerts"], + {"${TopicName}Arn": {"Value": {"Ref": "Topic${TopicName}"}}}, + ] + }, + } + + processor.process_template(context) + + # All ForEach constructs should be expanded + assert "IsDev" in context.fragment["Conditions"] + assert "IsProd" in context.fragment["Conditions"] + assert "TopicAlerts" in context.fragment["Resources"] + assert "AlertsArn" in context.fragment["Outputs"] + # ForEach keys should be removed + assert "Fn::ForEach::EnvConditions" not in context.fragment["Conditions"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + assert "Fn::ForEach::TopicOutputs" not in context.fragment["Outputs"] + + def test_handles_missing_sections(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that processor handles templates with missing optional sections.""" + context.fragment = { + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}} + # No Conditions or Outputs sections + } + + # Should not raise + processor.process_template(context) + + assert "MyBucket" in context.fragment["Resources"] + + def test_handles_empty_sections(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that processor handles empty sections.""" + context.fragment = {"Conditions": {}, "Resources": {}, "Outputs": {}} + + # Should not raise + processor.process_template(context) + + +class TestForEachProcessorCollectionResolution: + """Tests for ForEachProcessor collection resolution. + + Requirement 6.8: WHEN Fn::ForEach collection contains a Ref to a parameter, + THEN THE Resolver SHALL resolve the parameter value before iteration + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_resolves_ref_to_parameter_value(self, processor: ForEachProcessor): + """Test that Ref to parameter in collection is resolved and expanded. + + Requirement 6.8: Resolve parameter references in collections + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + {"Ref": "TopicNames"}, + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + }, + parameter_values={"TopicNames": ["Alerts", "Notifications", "Errors"]}, + ) + + processor.process_template(context) + + # The ForEach should be expanded using the resolved parameter value + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "TopicErrors" in context.fragment["Resources"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + def test_resolves_ref_to_empty_list_parameter(self, processor: ForEachProcessor): + """Test that Ref to parameter with empty list produces no outputs. + + Requirement 6.8: Resolve parameter references in collections + Requirement 6.5: Empty collection produces no outputs + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + {"Ref": "TopicNames"}, + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + }, + parameter_values={"TopicNames": []}, + ) + + processor.process_template(context) + + # Empty collection should produce no outputs + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + # No expanded resources should exist + assert len(context.fragment["Resources"]) == 0 + + def test_ref_to_nonexistent_parameter_raises(self, processor: ForEachProcessor): + """Test that Ref to non-existent parameter raises exception. + + When a Ref cannot be resolved and doesn't result in a list, + validation should fail. + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + {"Ref": "NonExistentParam"}, + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "layout is incorrect" in str(exc_info.value) + + def test_literal_list_collection_expanded(self, processor: ForEachProcessor): + """Test that literal list collections are expanded correctly.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + }, + parameter_values={}, + ) + + processor.process_template(context) + + # The ForEach should be expanded + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + def test_resolves_parameter_default_value(self, processor: ForEachProcessor): + """Test that parameter default values are used and expanded.""" + context = TemplateProcessingContext( + fragment={ + "Parameters": {"TopicNames": {"Type": "CommaDelimitedList", "Default": "Alerts,Notifications,Errors"}}, + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + {"Ref": "TopicNames"}, + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + }, + }, + parameter_values={}, + ) + + # Set up parsed template with parameters + context.parsed_template = ParsedTemplate( + parameters={"TopicNames": {"Type": "CommaDelimitedList", "Default": "Alerts,Notifications,Errors"}}, + resources={}, + ) + + processor.process_template(context) + + # The ForEach should be expanded using the default value + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "TopicErrors" in context.fragment["Resources"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + +class TestForEachProcessorWithIntrinsicResolver: + """Tests for ForEachProcessor with IntrinsicResolver integration.""" + + def test_uses_intrinsic_resolver_for_collection(self): + """Test that intrinsic resolver is used to resolve and expand collections.""" + from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicResolver + from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver + + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + {"Ref": "TopicNames"}, + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + }, + parameter_values={"TopicNames": ["Alerts", "Notifications"]}, + ) + + # Create intrinsic resolver with FnRefResolver + intrinsic_resolver = IntrinsicResolver(context) + intrinsic_resolver.register_resolver(FnRefResolver) + + processor = ForEachProcessor(intrinsic_resolver=intrinsic_resolver) + processor.process_template(context) + + # The ForEach should be expanded via the intrinsic resolver + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + +class TestForEachProcessorMultipleForEach: + """Tests for ForEachProcessor with multiple ForEach constructs.""" + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_multiple_foreach_in_same_section(self, processor: ForEachProcessor): + """Test that multiple ForEach constructs in same section are expanded.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ], + "Fn::ForEach::Queues": [ + "QueueName", + ["Orders", "Events"], + {"Queue${QueueName}": {"Type": "AWS::SQS::Queue"}}, + ], + "StaticResource": {"Type": "AWS::S3::Bucket"}, + } + } + ) + + processor.process_template(context) + + # Both ForEach constructs should be expanded + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "QueueOrders" in context.fragment["Resources"] + assert "QueueEvents" in context.fragment["Resources"] + # ForEach keys should be removed + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + assert "Fn::ForEach::Queues" not in context.fragment["Resources"] + # Static resource should be preserved + assert "StaticResource" in context.fragment["Resources"] + + def test_foreach_with_different_identifiers(self, processor: ForEachProcessor): + """Test ForEach constructs with different identifiers are expanded correctly.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Loop1": ["Item", ["A", "B"], {"Resource${Item}": {"Type": "AWS::SNS::Topic"}}], + "Fn::ForEach::Loop2": ["Name", ["X", "Y"], {"Other${Name}": {"Type": "AWS::SQS::Queue"}}], + } + } + ) + + processor.process_template(context) + + # Both should be expanded with correct identifiers + assert "ResourceA" in context.fragment["Resources"] + assert "ResourceB" in context.fragment["Resources"] + assert "OtherX" in context.fragment["Resources"] + assert "OtherY" in context.fragment["Resources"] + # ForEach keys should be removed + assert "Fn::ForEach::Loop1" not in context.fragment["Resources"] + assert "Fn::ForEach::Loop2" not in context.fragment["Resources"] + + +class TestForEachProcessorEdgeCases: + """Tests for ForEachProcessor edge cases.""" + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_foreach_with_single_item_collection(self, processor: ForEachProcessor): + """Test ForEach with single item in collection expands to one output.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["OnlyOne"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + ) + + processor.process_template(context) + + # Should expand to exactly one resource + assert "TopicOnlyOne" in context.fragment["Resources"] + assert len(context.fragment["Resources"]) == 1 + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + def test_foreach_with_numeric_collection_items(self, processor: ForEachProcessor): + """Test ForEach with numeric items in collection converts to strings.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Instances": ["Index", [1, 2, 3], {"Instance${Index}": {"Type": "AWS::EC2::Instance"}}] + } + } + ) + + processor.process_template(context) + + # Numeric items should be converted to strings for substitution + assert "Instance1" in context.fragment["Resources"] + assert "Instance2" in context.fragment["Resources"] + assert "Instance3" in context.fragment["Resources"] + assert "Fn::ForEach::Instances" not in context.fragment["Resources"] + + def test_foreach_with_complex_body(self, processor: ForEachProcessor): + """Test ForEach with complex template body expands correctly.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts"], + { + "Topic${TopicName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "${TopicName}Topic", + "Tags": [{"Key": "Name", "Value": "${TopicName}"}], + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + # Should expand with all substitutions + assert "TopicAlerts" in context.fragment["Resources"] + resource = context.fragment["Resources"]["TopicAlerts"] + assert resource["Properties"]["TopicName"] == "AlertsTopic" + assert resource["Properties"]["Tags"][0]["Value"] == "Alerts" + + def test_foreach_with_special_characters_in_loop_name(self, processor: ForEachProcessor): + """Test ForEach with special characters in loop name expands correctly.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::MyLoop123": ["Item", ["A"], {"Resource${Item}": {"Type": "AWS::SNS::Topic"}}] + } + } + ) + + processor.process_template(context) + + assert "ResourceA" in context.fragment["Resources"] + assert "Fn::ForEach::MyLoop123" not in context.fragment["Resources"] + + def test_foreach_preserves_non_foreach_keys(self, processor: ForEachProcessor): + """Test that non-ForEach keys are preserved unchanged.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "my-bucket"}}, + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ], + "MyQueue": {"Type": "AWS::SQS::Queue"}, + } + } + ) + + processor.process_template(context) + + # Non-ForEach resources should be preserved exactly + assert context.fragment["Resources"]["MyBucket"] == { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "my-bucket"}, + } + assert context.fragment["Resources"]["MyQueue"] == {"Type": "AWS::SQS::Queue"} + + +class TestForEachProcessorProtocol: + """Tests for ForEachProcessor implementing TemplateProcessor protocol.""" + + def test_implements_process_template_method(self): + """Test that ForEachProcessor has process_template method.""" + processor = ForEachProcessor() + assert hasattr(processor, "process_template") + assert callable(processor.process_template) + + def test_can_be_used_in_pipeline(self): + """Test that ForEachProcessor can be used in ProcessingPipeline.""" + from samcli.lib.cfn_language_extensions.pipeline import ProcessingPipeline + + processor = ForEachProcessor() + pipeline = ProcessingPipeline([processor]) + + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["Alerts"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + } + } + ) + + result = pipeline.process_template(context) + + # ForEach should be expanded + assert "TopicAlerts" in result["Resources"] + assert "Fn::ForEach::Topics" not in result["Resources"] + + def test_works_with_parsing_processor(self): + """Test that ForEachProcessor works after TemplateParsingProcessor.""" + from samcli.lib.cfn_language_extensions.pipeline import ProcessingPipeline + from samcli.lib.cfn_language_extensions.processors.parsing import TemplateParsingProcessor + + parsing_processor = TemplateParsingProcessor() + foreach_processor = ForEachProcessor() + pipeline = ProcessingPipeline([parsing_processor, foreach_processor]) + + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + ) + + result = pipeline.process_template(context) + + # Parsing should have set parsed_template + assert context.parsed_template is not None + # ForEach should be expanded + assert "TopicAlerts" in result["Resources"] + assert "TopicNotifications" in result["Resources"] + assert "Fn::ForEach::Topics" not in result["Resources"] + + +class TestForEachExpansionLogic: + """Tests for ForEach loop expansion logic (Task 12.2). + + Requirements: + - 6.1: Fn::ForEach in Resources SHALL expand to multiple resources + - 6.2: Fn::ForEach in Outputs SHALL expand to multiple outputs + - 6.3: Fn::ForEach in Conditions SHALL expand to multiple conditions + - 6.4: Nested Fn::ForEach SHALL expand recursively + - 6.5: Collection items SHALL be iterated in order + - 6.9: Identifier SHALL be substituted in both keys and values + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_single_foreach_expansion(self, processor: ForEachProcessor): + """Test that a single ForEach expands correctly. + + Requirement 6.1: Fn::ForEach in Resources SHALL expand to multiple resources + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications", "Errors"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + ) + + processor.process_template(context) + + # Should expand to 3 resources + assert len(context.fragment["Resources"]) == 3 + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + assert "TopicErrors" in context.fragment["Resources"] + # Original ForEach key should be removed + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + def test_multiple_items_in_collection(self, processor: ForEachProcessor): + """Test ForEach with multiple items produces correct number of outputs. + + Requirement 6.5: Collection items SHALL be iterated in order + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Buckets": [ + "BucketName", + ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"], + {"Bucket${BucketName}": {"Type": "AWS::S3::Bucket"}}, + ] + } + } + ) + + processor.process_template(context) + + # Should expand to 5 resources + assert len(context.fragment["Resources"]) == 5 + assert "BucketAlpha" in context.fragment["Resources"] + assert "BucketBeta" in context.fragment["Resources"] + assert "BucketGamma" in context.fragment["Resources"] + assert "BucketDelta" in context.fragment["Resources"] + assert "BucketEpsilon" in context.fragment["Resources"] + + def test_nested_foreach_expansion(self, processor: ForEachProcessor): + """Test that nested ForEach constructs expand recursively. + + Requirement 6.4: Nested Fn::ForEach SHALL expand recursively + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Environments": [ + "Env", + ["Dev", "Prod"], + { + "Fn::ForEach::Services": [ + "Service", + ["Api", "Web"], + { + "${Env}${Service}Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "${Env}-${Service}-bucket"}, + } + }, + ] + }, + ] + } + } + ) + + processor.process_template(context) + + # Should expand to 2 * 2 = 4 resources + assert len(context.fragment["Resources"]) == 4 + assert "DevApiBucket" in context.fragment["Resources"] + assert "DevWebBucket" in context.fragment["Resources"] + assert "ProdApiBucket" in context.fragment["Resources"] + assert "ProdWebBucket" in context.fragment["Resources"] + + # Verify nested substitution in properties + assert context.fragment["Resources"]["DevApiBucket"]["Properties"]["BucketName"] == "Dev-Api-bucket" + assert context.fragment["Resources"]["ProdWebBucket"]["Properties"]["BucketName"] == "Prod-Web-bucket" + + def test_identifier_substitution_in_keys(self, processor: ForEachProcessor): + """Test that identifier is substituted in dictionary keys. + + Requirement 6.9: Identifier SHALL be substituted in both keys and values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "Name", + ["Alerts"], + {"Topic${Name}": {"Type": "AWS::SNS::Topic", "Properties": {"${Name}Property": "value"}}}, + ] + } + } + ) + + processor.process_template(context) + + # Key should be substituted + assert "TopicAlerts" in context.fragment["Resources"] + # Property key should also be substituted + assert "AlertsProperty" in context.fragment["Resources"]["TopicAlerts"]["Properties"] + + def test_identifier_substitution_in_values(self, processor: ForEachProcessor): + """Test that identifier is substituted in values. + + Requirement 6.9: Identifier SHALL be substituted in both keys and values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts"], + { + "Topic${TopicName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": "${TopicName} Topic", + "TopicName": "my-${TopicName}-topic", + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + resource = context.fragment["Resources"]["TopicAlerts"] + assert resource["Properties"]["DisplayName"] == "Alerts Topic" + assert resource["Properties"]["TopicName"] == "my-Alerts-topic" + + def test_identifier_substitution_in_nested_structures(self, processor: ForEachProcessor): + """Test that identifier is substituted in deeply nested structures. + + Requirement 6.9: Identifier SHALL be substituted in both keys and values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "Name", + ["Alerts"], + { + "Topic${Name}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Tags": [ + {"Key": "Name", "Value": "${Name}"}, + {"Key": "Environment", "Value": "${Name}-env"}, + ], + "Subscription": [{"Endpoint": "https://example.com/${Name}", "Protocol": "https"}], + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + resource = context.fragment["Resources"]["TopicAlerts"] + assert resource["Properties"]["Tags"][0]["Value"] == "Alerts" + assert resource["Properties"]["Tags"][1]["Value"] == "Alerts-env" + assert resource["Properties"]["Subscription"][0]["Endpoint"] == "https://example.com/Alerts" + + def test_empty_collection_produces_no_outputs(self, processor: ForEachProcessor): + """Test that empty collection produces no outputs. + + Requirement 6.5: Empty collection produces no outputs + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + [], # Empty collection + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ], + "StaticResource": {"Type": "AWS::S3::Bucket"}, + } + } + ) + + processor.process_template(context) + + # Only the static resource should remain + assert len(context.fragment["Resources"]) == 1 + assert "StaticResource" in context.fragment["Resources"] + assert "Fn::ForEach::Topics" not in context.fragment["Resources"] + + def test_collection_items_iterated_in_order(self, processor: ForEachProcessor): + """Test that collection items are iterated in order. + + Requirement 6.5: Collection items SHALL be iterated in order + """ + context = TemplateProcessingContext( + fragment={ + "Outputs": { + "Fn::ForEach::Values": [ + "Item", + ["First", "Second", "Third"], + {"Output${Item}": {"Value": "${Item}"}}, + ] + } + } + ) + + processor.process_template(context) + + # Verify all outputs exist with correct values + assert context.fragment["Outputs"]["OutputFirst"]["Value"] == "First" + assert context.fragment["Outputs"]["OutputSecond"]["Value"] == "Second" + assert context.fragment["Outputs"]["OutputThird"]["Value"] == "Third" + + def test_deeply_nested_foreach(self, processor: ForEachProcessor): + """Test three levels of nested ForEach.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Regions": [ + "Region", + ["East", "West"], + { + "Fn::ForEach::Environments": [ + "Env", + ["Dev", "Prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Api"], + { + "${Region}${Env}${Svc}": { + "Type": "AWS::Lambda::Function", + "Properties": {"FunctionName": "${Region}-${Env}-${Svc}"}, + } + }, + ] + }, + ] + }, + ] + } + } + ) + + processor.process_template(context) + + # Should expand to 2 * 2 * 1 = 4 resources + assert len(context.fragment["Resources"]) == 4 + assert "EastDevApi" in context.fragment["Resources"] + assert "EastProdApi" in context.fragment["Resources"] + assert "WestDevApi" in context.fragment["Resources"] + assert "WestProdApi" in context.fragment["Resources"] + + # Verify property substitution + assert context.fragment["Resources"]["EastDevApi"]["Properties"]["FunctionName"] == "East-Dev-Api" + assert context.fragment["Resources"]["WestProdApi"]["Properties"]["FunctionName"] == "West-Prod-Api" + + def test_foreach_preserves_non_string_values(self, processor: ForEachProcessor): + """Test that non-string values (int, bool, etc.) are preserved.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Queues": [ + "Name", + ["Orders"], + { + "Queue${Name}": { + "Type": "AWS::SQS::Queue", + "Properties": { + "DelaySeconds": 30, + "FifoQueue": True, + "MaximumMessageSize": 262144, + "QueueName": "${Name}Queue", + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + resource = context.fragment["Resources"]["QueueOrders"] + assert resource["Properties"]["DelaySeconds"] == 30 + assert resource["Properties"]["FifoQueue"] is True + assert resource["Properties"]["MaximumMessageSize"] == 262144 + assert resource["Properties"]["QueueName"] == "OrdersQueue" + + def test_foreach_with_intrinsic_functions_in_body(self, processor: ForEachProcessor): + """Test that intrinsic functions in body are preserved during expansion.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "Name", + ["Alerts"], + { + "Topic${Name}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": {"Fn::Sub": "${Name}-topic"}, + "KmsMasterKeyId": {"Ref": "KmsKey"}, + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + resource = context.fragment["Resources"]["TopicAlerts"] + # Fn::Sub should be preserved but with identifier substituted + assert resource["Properties"]["TopicName"] == {"Fn::Sub": "Alerts-topic"} + # Ref should be preserved unchanged + assert resource["Properties"]["KmsMasterKeyId"] == {"Ref": "KmsKey"} + + def test_foreach_expansion_in_outputs_with_refs(self, processor: ForEachProcessor): + """Test ForEach expansion in Outputs section with Ref intrinsics. + + Requirement 6.2: Fn::ForEach in Outputs SHALL expand to multiple outputs + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Outputs": { + "Fn::ForEach::TopicArns": [ + "TopicName", + ["Alerts", "Notifications"], + { + "${TopicName}TopicArn": { + "Description": "ARN of ${TopicName} topic", + "Value": {"Ref": "Topic${TopicName}"}, + "Export": {"Name": "${TopicName}-topic-arn"}, + } + }, + ] + }, + } + ) + + processor.process_template(context) + + # Should expand to 2 outputs + assert len(context.fragment["Outputs"]) == 2 + + alerts_output = context.fragment["Outputs"]["AlertsTopicArn"] + assert alerts_output["Description"] == "ARN of Alerts topic" + assert alerts_output["Value"] == {"Ref": "TopicAlerts"} + assert alerts_output["Export"]["Name"] == "Alerts-topic-arn" + + notifications_output = context.fragment["Outputs"]["NotificationsTopicArn"] + assert notifications_output["Description"] == "ARN of Notifications topic" + assert notifications_output["Value"] == {"Ref": "TopicNotifications"} + assert notifications_output["Export"]["Name"] == "Notifications-topic-arn" + + def test_foreach_expansion_in_conditions(self, processor: ForEachProcessor): + """Test ForEach expansion in Conditions section. + + Requirement 6.3: Fn::ForEach in Conditions SHALL expand to multiple conditions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Conditions": { + "Fn::ForEach::EnvConditions": [ + "Env", + ["Development", "Staging", "Production"], + {"Is${Env}": {"Fn::Equals": [{"Ref": "Environment"}, "${Env}"]}}, + ] + }, + } + ) + + processor.process_template(context) + + # Should expand to 3 conditions + assert len(context.fragment["Conditions"]) == 3 + + assert "IsDevelopment" in context.fragment["Conditions"] + assert "IsStaging" in context.fragment["Conditions"] + assert "IsProduction" in context.fragment["Conditions"] + + # Verify condition structure + dev_condition = context.fragment["Conditions"]["IsDevelopment"] + assert dev_condition["Fn::Equals"][1] == "Development" + + prod_condition = context.fragment["Conditions"]["IsProduction"] + assert prod_condition["Fn::Equals"][1] == "Production" + + +class TestForEachIdentifierConflictDetection: + """Tests for ForEach identifier conflict detection. + + Requirement 6.6: WHEN Fn::ForEach identifier conflicts with an existing parameter name + or another loop identifier, THEN THE Resolver SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + def test_identifier_conflicts_with_parameter_name(self, processor: ForEachProcessor): + """Test that identifier conflicting with parameter name raises exception. + + Requirement 6.6: Raise Invalid_Template_Exception for identifier conflicts + """ + context = TemplateProcessingContext( + fragment={ + "Parameters": {"TopicName": {"Type": "String", "Default": "MyTopic"}}, + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", # Conflicts with parameter name + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "identifier 'TopicName' conflicts with parameter name" in str(exc_info.value) + + def test_identifier_conflicts_with_parameter_name_from_parsed_template(self, processor: ForEachProcessor): + """Test that identifier conflicting with parameter from parsed_template raises exception. + + Requirement 6.6: Raise Invalid_Template_Exception for identifier conflicts + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": [ + "Environment", # Conflicts with parameter name + ["Dev", "Prod"], + {"${Environment}Bucket": {"Type": "AWS::S3::Bucket"}}, + ] + } + } + ) + + # Set up parsed template with parameters + context.parsed_template = ParsedTemplate( + parameters={"Environment": {"Type": "String", "Default": "Dev"}}, resources={} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "identifier 'Environment' conflicts with parameter name" in str(exc_info.value) + + def test_identifier_conflicts_with_nested_loop_identifier(self, processor: ForEachProcessor): + """Test that identifier conflicting with nested loop identifier raises exception. + + Requirement 6.6: Raise Invalid_Template_Exception for identifier conflicts + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Outer": [ + "Name", + ["A", "B"], + { + "Fn::ForEach::Inner": [ + "Name", # Conflicts with outer loop identifier + ["X", "Y"], + {"${Name}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "identifier 'Name' conflicts with another loop identifier" in str(exc_info.value) + + def test_identifier_conflicts_with_deeply_nested_loop_identifier(self, processor: ForEachProcessor): + """Test that identifier conflicting with deeply nested loop identifier raises exception. + + Requirement 6.6: Raise Invalid_Template_Exception for identifier conflicts + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Level1": [ + "Env", + ["Dev", "Prod"], + { + "Fn::ForEach::Level2": [ + "Region", + ["East", "West"], + { + "Fn::ForEach::Level3": [ + "Env", # Conflicts with Level1 identifier + ["Api", "Web"], + {"${Env}${Region}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + }, + ] + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "identifier 'Env' conflicts with another loop identifier" in str(exc_info.value) + + def test_valid_identifier_no_conflict_with_parameters(self, processor: ForEachProcessor): + """Test that valid identifier without conflicts passes validation.""" + context = TemplateProcessingContext( + fragment={ + "Parameters": {"Environment": {"Type": "String", "Default": "Dev"}}, + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", # Does not conflict with "Environment" + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + ) + + # Should not raise + processor.process_template(context) + + # Verify expansion worked + assert "TopicAlerts" in context.fragment["Resources"] + assert "TopicNotifications" in context.fragment["Resources"] + + def test_valid_nested_identifiers_no_conflict(self, processor: ForEachProcessor): + """Test that valid nested identifiers without conflicts pass validation.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Outer": [ + "Env", + ["Dev", "Prod"], + { + "Fn::ForEach::Inner": [ + "Service", # Different from "Env" + ["Api", "Web"], + {"${Env}${Service}Bucket": {"Type": "AWS::S3::Bucket"}}, + ] + }, + ] + } + } + ) + + # Should not raise + processor.process_template(context) + + # Verify expansion worked + assert "DevApiBucket" in context.fragment["Resources"] + assert "DevWebBucket" in context.fragment["Resources"] + assert "ProdApiBucket" in context.fragment["Resources"] + assert "ProdWebBucket" in context.fragment["Resources"] + + def test_valid_three_level_nested_identifiers(self, processor: ForEachProcessor): + """Test that valid three-level nested identifiers pass validation.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Level1": [ + "Env", + ["Dev"], + { + "Fn::ForEach::Level2": [ + "Region", + ["East"], + { + "Fn::ForEach::Level3": [ + "Service", # All different identifiers + ["Api"], + {"${Env}${Region}${Service}": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + }, + ] + } + } + ) + + # Should not raise + processor.process_template(context) + + # Verify expansion worked + assert "DevEastApi" in context.fragment["Resources"] + + def test_identifier_conflict_with_multiple_parameters(self, processor: ForEachProcessor): + """Test identifier conflict detection with multiple parameters.""" + context = TemplateProcessingContext( + fragment={ + "Parameters": { + "Environment": {"Type": "String"}, + "Region": {"Type": "String"}, + "ServiceName": {"Type": "String"}, + }, + "Resources": { + "Fn::ForEach::Services": [ + "Region", # Conflicts with parameter "Region" + ["East", "West"], + {"${Region}Service": {"Type": "AWS::Lambda::Function"}}, + ] + }, + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "identifier 'Region' conflicts with parameter name" in str(exc_info.value) + + def test_no_conflict_with_empty_parameters(self, processor: ForEachProcessor): + """Test that identifier validation works with empty parameters section.""" + context = TemplateProcessingContext( + fragment={ + "Parameters": {}, + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["Alerts"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + }, + } + ) + + # Should not raise + processor.process_template(context) + + assert "TopicAlerts" in context.fragment["Resources"] + + def test_no_conflict_without_parameters_section(self, processor: ForEachProcessor): + """Test that identifier validation works without parameters section.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": ["TopicName", ["Alerts"], {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}] + } + } + ) + + # Should not raise + processor.process_template(context) + + assert "TopicAlerts" in context.fragment["Resources"] + + def test_sibling_foreach_can_use_same_identifier(self, processor: ForEachProcessor): + """Test that sibling ForEach constructs can use the same identifier. + + Sibling loops (not nested) should be allowed to use the same identifier + since they don't have overlapping scopes. + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Fn::ForEach::Topics": ["Name", ["Alerts"], {"Topic${Name}": {"Type": "AWS::SNS::Topic"}}], + "Fn::ForEach::Queues": [ + "Name", # Same identifier as Topics, but sibling not nested + ["Orders"], + {"Queue${Name}": {"Type": "AWS::SQS::Queue"}}, + ], + } + } + ) + + # Should not raise - sibling loops can use same identifier + processor.process_template(context) + + assert "TopicAlerts" in context.fragment["Resources"] + assert "QueueOrders" in context.fragment["Resources"] + + +# ============================================================================= +# Property-Based Tests for ForEach Processor +# ============================================================================= + + +# ============================================================================= +# Parametrized Tests for ForEach Processor +# ============================================================================= + + +class TestForEachExpansionCountProperty: + """Parametrized tests for ForEach expansion count. + + Property 9: Fn::ForEach Expansion Count + For any Fn::ForEach with a collection of N items, the expansion SHALL produce + exactly N outputs. + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta", "Gamma"], "Topics"), + ("Name", [], "Empty"), + ("Svc", ["A"], "Single"), + ], + ) + def test_foreach_resources_expansion_count(self, identifier, collection_items, loop_name): + """ + Property 9: A collection with N items produces exactly N expanded resources. + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + {f"Resource${{{identifier}}}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + ) + + processor.process_template(context) + + assert len(context.fragment["Resources"]) == len(collection_items) + for item in collection_items: + assert f"Resource{item}" in context.fragment["Resources"] + assert f"Fn::ForEach::{loop_name}" not in context.fragment["Resources"] + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["X", "Y", "Z"], "Outputs"), + ("Name", [], "EmptyOutputs"), + ("Svc", ["One", "Two"], "TwoOutputs"), + ], + ) + def test_foreach_outputs_expansion_count(self, identifier, collection_items, loop_name): + """ + Property 9: A collection with N items in Outputs produces exactly N outputs. + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Outputs": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + {f"Output${{{identifier}}}": {"Value": f"${{{identifier}}}"}}, + ] + }, + } + ) + + processor.process_template(context) + + if len(collection_items) == 0: + assert "Outputs" not in context.fragment or len(context.fragment.get("Outputs", {})) == 0 + else: + assert len(context.fragment["Outputs"]) == len(collection_items) + for item in collection_items: + assert f"Output{item}" in context.fragment["Outputs"] + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Env", ["prod", "dev", "staging"], "Conditions"), + ("Name", [], "EmptyConds"), + ("Item", ["Alpha"], "SingleCond"), + ], + ) + def test_foreach_conditions_expansion_count(self, identifier, collection_items, loop_name): + """ + Property 9: A collection with N items in Conditions produces exactly N conditions. + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Conditions": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + {f"Is${{{identifier}}}": {"Fn::Equals": [{"Ref": "Env"}, f"${{{identifier}}}"]}}, + ] + }, + } + ) + + processor.process_template(context) + + if len(collection_items) == 0: + assert "Conditions" not in context.fragment or len(context.fragment.get("Conditions", {})) == 0 + else: + assert len(context.fragment["Conditions"]) == len(collection_items) + for item in collection_items: + assert f"Is{item}" in context.fragment["Conditions"] + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta", "Gamma"], "OrderTest"), + ("Svc", ["X", "Y"], "TwoItems"), + ], + ) + def test_foreach_expansion_preserves_iteration_order(self, identifier, collection_items, loop_name): + """ + Property 9: Collection items are iterated in order and substituted values match. + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Outputs": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + {f"Output${{{identifier}}}": {"Value": f"${{{identifier}}}"}}, + ] + }, + } + ) + + processor.process_template(context) + + for item in collection_items: + assert context.fragment["Outputs"][f"Output{item}"]["Value"] == item + + +class TestForEachNestedExpansionProperty: + """Parametrized tests for nested ForEach expansion. + + Property 10: Fn::ForEach Nested Expansion + For any nested Fn::ForEach with outer collection of M items and inner collection + of N items, the expansion SHALL produce M × N outputs. + + **Validates: Requirements 6.4** + """ + + @pytest.mark.parametrize( + "outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop", + [ + ("Env", "Svc", ["prod", "dev"], ["Api", "Web"], "Envs", "Services"), + ("Region", "Tier", ["us", "eu", "ap"], ["front", "back"], "Regions", "Tiers"), + ], + ) + def test_nested_foreach_produces_m_times_n_outputs( + self, outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop + ): + """ + Property 10: Nested ForEach with M outer and N inner items produces M*N resources. + + **Validates: Requirements 6.4** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{outer_loop}": [ + outer_id, + outer_items, + { + f"Fn::ForEach::{inner_loop}": [ + inner_id, + inner_items, + {f"Resource${{{outer_id}}}${{{inner_id}}}": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + } + } + ) + + processor.process_template(context) + + expected_count = len(outer_items) * len(inner_items) + assert len(context.fragment["Resources"]) == expected_count + + for outer_item in outer_items: + for inner_item in inner_items: + assert f"Resource{outer_item}{inner_item}" in context.fragment["Resources"] + + @pytest.mark.parametrize( + "outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop", + [ + ("Env", "Svc", ["prod", "dev"], ["Api", "Web"], "Envs", "Services"), + ("Region", "Tier", ["us", "eu"], ["front", "back", "data"], "Regions", "Tiers"), + ], + ) + def test_nested_foreach_correct_variable_substitution( + self, outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop + ): + """ + Property 10: Nested ForEach correctly substitutes variables at each level. + + **Validates: Requirements 6.4** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{outer_loop}": [ + outer_id, + outer_items, + { + f"Fn::ForEach::{inner_loop}": [ + inner_id, + inner_items, + { + f"Resource${{{outer_id}}}${{{inner_id}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "OuterValue": f"${{{outer_id}}}", + "InnerValue": f"${{{inner_id}}}", + "CombinedValue": f"${{{outer_id}}}-${{{inner_id}}}", + }, + } + }, + ] + }, + ] + } + } + ) + + processor.process_template(context) + + for outer_item in outer_items: + for inner_item in inner_items: + resource = context.fragment["Resources"][f"Resource{outer_item}{inner_item}"] + assert resource["Properties"]["OuterValue"] == outer_item + assert resource["Properties"]["InnerValue"] == inner_item + assert resource["Properties"]["CombinedValue"] == f"{outer_item}-{inner_item}" + + @pytest.mark.parametrize( + "outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop", + [ + ("Env", "Svc", ["prod", "dev"], ["Api", "Web"], "Envs", "Services"), + ], + ) + def test_nested_foreach_in_outputs_section( + self, outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop + ): + """ + Property 10: Nested ForEach in Outputs produces M*N outputs. + + **Validates: Requirements 6.4** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Outputs": { + f"Fn::ForEach::{outer_loop}": [ + outer_id, + outer_items, + { + f"Fn::ForEach::{inner_loop}": [ + inner_id, + inner_items, + { + f"Output${{{outer_id}}}${{{inner_id}}}": { + "Value": f"${{{outer_id}}}-${{{inner_id}}}" + } + }, + ] + }, + ] + }, + } + ) + + processor.process_template(context) + + expected_count = len(outer_items) * len(inner_items) + assert len(context.fragment["Outputs"]) == expected_count + + for outer_item in outer_items: + for inner_item in inner_items: + output_key = f"Output{outer_item}{inner_item}" + assert context.fragment["Outputs"][output_key]["Value"] == f"{outer_item}-{inner_item}" + + @pytest.mark.parametrize( + "outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop", + [ + ("Env", "Svc", ["prod", "dev"], ["Api", "Web"], "Envs", "Services"), + ], + ) + def test_nested_foreach_in_conditions_section( + self, outer_id, inner_id, outer_items, inner_items, outer_loop, inner_loop + ): + """ + Property 10: Nested ForEach in Conditions produces M*N conditions. + + **Validates: Requirements 6.4** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {}, + "Conditions": { + f"Fn::ForEach::{outer_loop}": [ + outer_id, + outer_items, + { + f"Fn::ForEach::{inner_loop}": [ + inner_id, + inner_items, + { + f"Is${{{outer_id}}}${{{inner_id}}}": { + "Fn::Equals": [{"Ref": "Env"}, f"${{{outer_id}}}-${{{inner_id}}}"] + } + }, + ] + }, + ] + }, + } + ) + + processor.process_template(context) + + expected_count = len(outer_items) * len(inner_items) + assert len(context.fragment["Conditions"]) == expected_count + + for outer_item in outer_items: + for inner_item in inner_items: + cond_key = f"Is{outer_item}{inner_item}" + assert context.fragment["Conditions"][cond_key]["Fn::Equals"][1] == f"{outer_item}-{inner_item}" + + +class TestForEachIdentifierSubstitutionProperty: + """Parametrized tests for ForEach identifier substitution. + + Property 11: Fn::ForEach Identifier Substitution + For any Fn::ForEach expansion, the loop variable identifier SHALL be substituted + in both logical IDs (keys) and all property values throughout the template body. + + **Validates: Requirements 6.9** + """ + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta"], "Topics"), + ("Svc", ["Api", "Web", "Worker"], "Services"), + ], + ) + def test_identifier_substituted_in_dictionary_keys(self, identifier, collection_items, loop_name): + """ + Property 11: ${identifier} in dictionary keys is replaced with the collection item value. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::SNS::Topic", + "Properties": {f"${{{identifier}}}Property": "value"}, + } + }, + ] + } + } + ) + + processor.process_template(context) + + for item in collection_items: + assert f"Resource{item}" in context.fragment["Resources"] + resource = context.fragment["Resources"][f"Resource{item}"] + assert f"{item}Property" in resource["Properties"] + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta"], "Topics"), + ("Svc", ["Api", "Web", "Worker"], "Services"), + ], + ) + def test_identifier_substituted_in_string_values(self, identifier, collection_items, loop_name): + """ + Property 11: ${identifier} in string values is replaced with the collection item value. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "SingleSubstitution": f"${{{identifier}}}", + "PrefixSubstitution": f"prefix-${{{identifier}}}", + "SuffixSubstitution": f"${{{identifier}}}-suffix", + "MultipleSubstitution": f"${{{identifier}}}-middle-${{{identifier}}}", + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + for item in collection_items: + props = context.fragment["Resources"][f"Resource{item}"]["Properties"] + assert props["SingleSubstitution"] == item + assert props["PrefixSubstitution"] == f"prefix-{item}" + assert props["SuffixSubstitution"] == f"{item}-suffix" + assert props["MultipleSubstitution"] == f"{item}-middle-{item}" + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta"], "Topics"), + ("Svc", ["Api", "Web"], "Services"), + ], + ) + def test_identifier_substituted_in_nested_dictionary_keys_and_values(self, identifier, collection_items, loop_name): + """ + Property 11: ${identifier} in nested dictionary keys and values is replaced. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Level1": { + f"${{{identifier}}}Key": f"${{{identifier}}}Value", + "Level2": {f"Nested${{{identifier}}}Key": f"Nested${{{identifier}}}Value"}, + } + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + for item in collection_items: + level1 = context.fragment["Resources"][f"Resource{item}"]["Properties"]["Level1"] + assert f"{item}Key" in level1 + assert level1[f"{item}Key"] == f"{item}Value" + level2 = level1["Level2"] + assert f"Nested{item}Key" in level2 + assert level2[f"Nested{item}Key"] == f"Nested{item}Value" + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta"], "Topics"), + ("Svc", ["Api", "Web", "Worker"], "Services"), + ], + ) + def test_identifier_substituted_in_list_items(self, identifier, collection_items, loop_name): + """ + Property 11: ${identifier} in list items is replaced. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "StringList": [ + f"${{{identifier}}}", + f"prefix-${{{identifier}}}", + f"${{{identifier}}}-suffix", + ], + "Tags": [ + {"Key": "Name", "Value": f"${{{identifier}}}"}, + {"Key": f"${{{identifier}}}Tag", "Value": "static"}, + ], + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + for item in collection_items: + props = context.fragment["Resources"][f"Resource{item}"]["Properties"] + assert props["StringList"][0] == item + assert props["StringList"][1] == f"prefix-{item}" + assert props["StringList"][2] == f"{item}-suffix" + assert props["Tags"][0]["Value"] == item + assert props["Tags"][1]["Key"] == f"{item}Tag" + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name", + [ + ("Item", ["Alpha", "Beta"], "Topics"), + ("Svc", ["Api"], "SingleService"), + ], + ) + def test_all_occurrences_of_identifier_are_replaced(self, identifier, collection_items, loop_name): + """ + Property 11: ALL occurrences of ${identifier} SHALL be replaced. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + placeholder = f"${{{identifier}}}" + + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource{placeholder}": { + "Type": "AWS::SNS::Topic", + "Properties": { + f"{placeholder}Prop": f"{placeholder}", + "Nested": {f"Key{placeholder}": f"Value{placeholder}"}, + "List": [f"{placeholder}", {"Tag": f"{placeholder}"}], + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + def contains_placeholder(obj, ph): + if isinstance(obj, str): + return ph in obj + elif isinstance(obj, dict): + return any(contains_placeholder(k, ph) or contains_placeholder(v, ph) for k, v in obj.items()) + elif isinstance(obj, list): + return any(contains_placeholder(i, ph) for i in obj) + return False + + for resource_key, resource_value in context.fragment["Resources"].items(): + assert not contains_placeholder(resource_key, placeholder) + assert not contains_placeholder(resource_value, placeholder) + + @pytest.mark.parametrize( + "identifier, collection_items, loop_name, static_int, static_bool", + [ + ("Item", ["Alpha", "Beta"], "Topics", 42, True), + ("Svc", ["Api", "Web"], "Services", 0, False), + ], + ) + def test_non_string_values_preserved_during_substitution( + self, identifier, collection_items, loop_name, static_int, static_bool + ): + """ + Property 11: Non-string values (integers, booleans, None) SHALL be preserved. + + **Validates: Requirements 6.9** + """ + processor = ForEachProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + f"Fn::ForEach::{loop_name}": [ + identifier, + collection_items, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "IntValue": static_int, + "BoolValue": static_bool, + "NullValue": None, + "StringValue": f"${{{identifier}}}", + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + for item in collection_items: + props = context.fragment["Resources"][f"Resource{item}"]["Properties"] + assert props["IntValue"] == static_int + assert isinstance(props["IntValue"], int) + assert props["BoolValue"] == static_bool + assert isinstance(props["BoolValue"], bool) + assert props["NullValue"] is None + assert props["StringValue"] == item + + +class TestForEachProcessorCloudDependentCollectionValidation: + """Tests for ForEachProcessor validation of cloud-dependent collections. + + Requirements: + - 5.1: Raise error for Fn::GetAtt in collection + - 5.2: Raise error for Fn::ImportValue in collection + - 5.3: Raise error for SSM/Secrets Manager dynamic references + - 5.4: Error message explains collection cannot be resolved locally + - 5.5: Error message suggests parameter workaround + - 5.6: Static list collections work correctly + - 5.7: Parameter references work correctly + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + def test_fn_getatt_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that Fn::GetAtt in collection raises InvalidTemplateException. + + Requirement 5.1: Raise error for Fn::GetAtt in collection + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Fn::GetAtt": ["SomeResource", "OutputList"]}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + # Requirement 5.4: Error message explains collection cannot be resolved locally + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "Fn::GetAtt" in error_message + # Requirement 5.5: Error message suggests parameter workaround + assert "Workaround" in error_message + assert "parameter" in error_message.lower() + assert "--parameter-overrides" in error_message + + def test_fn_getatt_shorthand_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that !GetAtt shorthand in collection raises InvalidTemplateException. + + Requirement 5.1: Raise error for Fn::GetAtt in collection + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"!GetAtt": "SomeResource.OutputList"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + + def test_fn_importvalue_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that Fn::ImportValue in collection raises InvalidTemplateException. + + Requirement 5.2: Raise error for Fn::ImportValue in collection + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Fn::ImportValue": "SharedFunctionNames"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + # Requirement 5.4: Error message explains collection cannot be resolved locally + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "Fn::ImportValue" in error_message + # Requirement 5.5: Error message suggests parameter workaround + assert "Workaround" in error_message + assert "parameter" in error_message.lower() + + def test_fn_importvalue_shorthand_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that !ImportValue shorthand in collection raises InvalidTemplateException. + + Requirement 5.2: Raise error for Fn::ImportValue in collection + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"!ImportValue": "SharedFunctionNames"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + + def test_ssm_dynamic_reference_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that SSM dynamic reference in collection raises InvalidTemplateException. + + Requirement 5.3: Raise error for SSM/Secrets Manager dynamic references + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + "{{resolve:ssm:/my/function/names}}", + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + # Requirement 5.4: Error message explains collection cannot be resolved locally + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "Systems Manager Parameter Store" in error_message + # Requirement 5.5: Error message suggests parameter workaround + assert "Workaround" in error_message + assert "parameter" in error_message.lower() + + def test_ssm_secure_dynamic_reference_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that SSM SecureString dynamic reference in collection raises error. + + Requirement 5.3: Raise error for SSM/Secrets Manager dynamic references + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + "{{resolve:ssm-secure:/my/secure/names}}", + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "SecureString" in error_message + + def test_secretsmanager_dynamic_reference_in_collection_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that Secrets Manager dynamic reference in collection raises error. + + Requirement 5.3: Raise error for SSM/Secrets Manager dynamic references + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + "{{resolve:secretsmanager:my-secret:SecretString:names}}", + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "Secrets Manager" in error_message + + def test_static_list_collection_succeeds(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that static list collections work correctly. + + Requirement 5.6: Static list collections work correctly + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta", "Gamma"], + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + # Should not raise + processor.process_template(context) + + # Verify expansion + assert "AlphaFunction" in context.fragment["Resources"] + assert "BetaFunction" in context.fragment["Resources"] + assert "GammaFunction" in context.fragment["Resources"] + + def test_parameter_reference_collection_succeeds( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that parameter reference collections work correctly. + + Requirement 5.7: Parameter references work correctly + """ + context.fragment = { + "Parameters": {"FunctionNames": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta,Gamma"}}, + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Ref": "FunctionNames"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + }, + } + context.parameter_values = {"FunctionNames": ["Alpha", "Beta", "Gamma"]} + + # Should not raise + processor.process_template(context) + + # Verify expansion + assert "AlphaFunction" in context.fragment["Resources"] + assert "BetaFunction" in context.fragment["Resources"] + assert "GammaFunction" in context.fragment["Resources"] + + def test_parameter_reference_with_default_value_succeeds( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that parameter reference with default value works correctly. + + Requirement 5.7: Parameter references work correctly + """ + context.fragment = { + "Parameters": {"FunctionNames": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta"}}, + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Ref": "FunctionNames"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + }, + } + # No parameter_values provided, should use default + + # Should not raise + processor.process_template(context) + + # Verify expansion using default value + assert "AlphaFunction" in context.fragment["Resources"] + assert "BetaFunction" in context.fragment["Resources"] + + def test_fn_getatt_in_collection_item_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that Fn::GetAtt in a collection item raises error. + + Requirement 5.1: Raise error for Fn::GetAtt in collection + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", {"Fn::GetAtt": ["SomeResource", "Name"]}, "Gamma"], + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + assert "Fn::GetAtt" in error_message + + def test_ssm_dynamic_reference_in_collection_item_raises_error( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that SSM dynamic reference in a collection item raises error. + + Requirement 5.3: Raise error for SSM/Secrets Manager dynamic references + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "{{resolve:ssm:/my/name}}", "Gamma"], + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Unable to resolve Fn::ForEach collection locally" in error_message + + def test_error_message_includes_target_info(self, processor: ForEachProcessor, context: TemplateProcessingContext): + """Test that error message includes the target information. + + Requirement 5.4: Error message explains collection cannot be resolved locally + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Fn::GetAtt": ["MyDynamoDB", "StreamArn"]}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + # Should include target information + assert "MyDynamoDB" in error_message or "StreamArn" in error_message + + def test_error_message_includes_workaround_example( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that error message includes a complete workaround example. + + Requirement 5.5: Error message suggests parameter workaround + """ + context.fragment = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + {"Fn::ImportValue": "SharedNames"}, + {"${Name}Function": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + # Should include workaround with parameter definition + assert "Parameters:" in error_message + assert "CommaDelimitedList" in error_message + assert "!Ref" in error_message or "Ref" in error_message + assert "--parameter-overrides" in error_message + + +class TestForEachProcessorAdditionalEdgeCases: + """Additional edge case tests for ForEach processor.""" + + def test_foreach_with_empty_collection(self): + """Test ForEach with empty collection produces no resources.""" + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + [], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + result = process_template(template) + assert result.get("Resources", {}) == {} + + def test_foreach_with_boolean_values_in_collection(self): + """Test ForEach with boolean values in collection.""" + from samcli.lib.cfn_language_extensions import process_template + + template = { + "Resources": { + "Fn::ForEach::Flags": [ + "Flag", + [True, False], + {"Resource${Flag}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + result = process_template(template) + assert "Resourcetrue" in result["Resources"] + assert "Resourcefalse" in result["Resources"] + + +class TestForEachProcessorNonDictSections: + """Tests for ForEach processor handling of non-dict sections.""" + + @pytest.fixture + def processor(self) -> ForEachProcessor: + return ForEachProcessor() + + def test_non_dict_resources_section_returned_as_is(self, processor: ForEachProcessor): + """Test that non-dict Resources section is returned unchanged.""" + context = TemplateProcessingContext(fragment={"Resources": "not-a-dict"}) + processor.process_template(context) + assert context.fragment["Resources"] == "not-a-dict" + + def test_non_dict_outputs_section_returned_as_is(self, processor: ForEachProcessor): + """Test that non-dict Outputs section is returned unchanged.""" + context = TemplateProcessingContext(fragment={"Resources": {}, "Outputs": "not-a-dict"}) + processor.process_template(context) + assert context.fragment["Outputs"] == "not-a-dict" + + def test_non_dict_conditions_section_returned_as_is(self, processor: ForEachProcessor): + """Test that non-dict Conditions section is returned unchanged.""" + context = TemplateProcessingContext(fragment={"Resources": {}, "Conditions": "not-a-dict"}) + processor.process_template(context) + assert context.fragment["Conditions"] == "not-a-dict" + + +class TestForEachProcessorNestingDepthValidation: + """Tests for ForEach processor nesting depth validation. + + CloudFormation enforces a maximum nesting depth of 5 for Fn::ForEach loops. + These tests verify that SAM CLI validates this limit before processing. + + Requirements: + - 18.1: Count nesting depth starting from 1 for outermost loop + - 18.2: Accept templates with 5 or fewer levels of nesting + - 18.3: Reject templates with more than 5 levels of nesting + - 18.4: Error message indicates maximum nesting depth of 5 + - 18.5: Error message indicates actual nesting depth found + """ + + @pytest.fixture + def processor(self) -> ForEachProcessor: + """Create a ForEachProcessor for testing.""" + return ForEachProcessor() + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + def test_calculate_depth_single_foreach(self, processor: ForEachProcessor): + """Test depth calculation for single Fn::ForEach at top level. + + Requirement 18.1: Count nesting depth starting from 1 for outermost loop + """ + template = { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 1 + + def test_calculate_depth_nested_foreach_depth_2(self, processor: ForEachProcessor): + """Test depth calculation for two levels of nested Fn::ForEach.""" + template = { + "Fn::ForEach::Outer": [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Inner": [ + "Service", + ["api", "worker"], + {"${Env}${Service}Function": {"Type": "AWS::Serverless::Function"}}, + ] + }, + ] + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 2 + + def test_calculate_depth_nested_foreach_depth_5(self, processor: ForEachProcessor): + """Test depth calculation for maximum valid nesting (5 levels). + + Requirement 18.2: Accept templates with 5 or fewer levels of nesting + """ + template = { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + {"${V1}${V2}${V3}${V4}${V5}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + }, + ] + }, + ] + }, + ] + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 5 + + def test_calculate_depth_nested_foreach_depth_6(self, processor: ForEachProcessor): + """Test depth calculation for nesting that exceeds limit (6 levels).""" + template = { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + { + "Fn::ForEach::L6": [ + "V6", + ["f"], + { + "${V1}${V2}${V3}${V4}${V5}${V6}Resource": { + "Type": "AWS::SNS::Topic" + } + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 6 + + def test_calculate_depth_parallel_foreach_returns_max(self, processor: ForEachProcessor): + """Test that parallel ForEach blocks return the maximum depth of all branches.""" + template = { + "Fn::ForEach::Shallow": [ + "Name", + ["a"], + {"${Name}Resource": {"Type": "AWS::SNS::Topic"}}, + ], + "Fn::ForEach::Deep": [ + "V1", + ["b"], + { + "Fn::ForEach::Nested": [ + "V2", + ["c"], + {"${V1}${V2}Resource": {"Type": "AWS::SQS::Queue"}}, + ] + }, + ], + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 2 # Maximum of 1 (Shallow) and 2 (Deep) + + def test_calculate_depth_empty_resources(self, processor: ForEachProcessor): + """Test depth calculation for empty Resources section.""" + template: dict = {} + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 0 + + def test_calculate_depth_no_foreach(self, processor: ForEachProcessor): + """Test depth calculation for template with no ForEach blocks.""" + template = { + "MyBucket": {"Type": "AWS::S3::Bucket"}, + "MyTopic": {"Type": "AWS::SNS::Topic"}, + } + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 0 + + def test_validate_depth_accepts_valid_template(self, processor: ForEachProcessor): + """Test that validation passes for templates with depth <= 5. + + Requirement 18.2: Accept templates with 5 or fewer levels of nesting + """ + template = { + "Resources": { + "Fn::ForEach::Topics": [ + "TopicName", + ["Alerts", "Notifications"], + {"Topic${TopicName}": {"Type": "AWS::SNS::Topic"}}, + ] + } + } + # Should not raise + processor._validate_foreach_nesting_depth(template) + + def test_validate_depth_accepts_depth_5(self, processor: ForEachProcessor): + """Test that validation passes for templates with exactly 5 levels. + + Requirement 18.2: Accept templates with 5 or fewer levels of nesting + """ + template = { + "Resources": { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + {"${V1}${V2}${V3}${V4}${V5}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + ] + }, + ] + }, + ] + }, + ] + } + } + # Should not raise + processor._validate_foreach_nesting_depth(template) + + def test_validate_depth_rejects_depth_6(self, processor: ForEachProcessor): + """Test that validation fails for templates with depth > 5. + + Requirements: + - 18.3: Reject templates with more than 5 levels of nesting + - 18.4: Error message indicates maximum nesting depth of 5 + - 18.5: Error message indicates actual nesting depth found + """ + template = { + "Resources": { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + { + "Fn::ForEach::L6": [ + "V6", + ["f"], + { + "${V1}${V2}${V3}${V4}${V5}${V6}Resource": { + "Type": "AWS::SNS::Topic" + } + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor._validate_foreach_nesting_depth(template) + + error_message = str(exc_info.value) + # Requirement 18.4: Error message indicates maximum nesting depth of 5 + assert "5" in error_message + # Requirement 18.5: Error message indicates actual nesting depth found + assert "6" in error_message + # Should mention the limit + assert "maximum" in error_message.lower() or "exceeds" in error_message.lower() + + def test_validate_depth_checks_all_sections(self, processor: ForEachProcessor): + """Test that validation checks Resources, Conditions, and Outputs sections.""" + # Deep nesting in Conditions section + template = { + "Resources": {}, + "Conditions": { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + { + "Fn::ForEach::L6": [ + "V6", + ["f"], + { + "Is${V1}${V2}${V3}${V4}${V5}${V6}": { + "Fn::Equals": ["a", "a"] + } + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + }, + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor._validate_foreach_nesting_depth(template) + + error_message = str(exc_info.value) + assert "6" in error_message + + def test_process_template_validates_depth_before_processing( + self, processor: ForEachProcessor, context: TemplateProcessingContext + ): + """Test that process_template validates nesting depth before processing. + + Requirement 18.7: Build command fails before attempting to build resources + """ + context.fragment = { + "Resources": { + "Fn::ForEach::L1": [ + "V1", + ["a"], + { + "Fn::ForEach::L2": [ + "V2", + ["b"], + { + "Fn::ForEach::L3": [ + "V3", + ["c"], + { + "Fn::ForEach::L4": [ + "V4", + ["d"], + { + "Fn::ForEach::L5": [ + "V5", + ["e"], + { + "Fn::ForEach::L6": [ + "V6", + ["f"], + { + "${V1}${V2}${V3}${V4}${V5}${V6}Resource": { + "Type": "AWS::SNS::Topic" + } + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + }, + ] + } + } + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "6" in error_message + assert "5" in error_message + + def test_calculate_depth_handles_malformed_foreach(self, processor: ForEachProcessor): + """Test depth calculation handles malformed ForEach syntax gracefully.""" + # ForEach with less than 3 elements + template = { + "Fn::ForEach::Malformed": ["V1", ["a"]], # Missing body + } + # Should still count this as depth 1 (the ForEach exists) + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 1 + + def test_calculate_depth_handles_non_list_foreach_value(self, processor: ForEachProcessor): + """Test depth calculation handles non-list ForEach value.""" + template = { + "Fn::ForEach::Invalid": "not-a-list", + } + # Should still count this as depth 1 (the ForEach key exists) + depth = processor._calculate_max_foreach_depth(template, current_depth=0) + assert depth == 1 diff --git a/tests/unit/lib/cfn_language_extensions/test_models.py b/tests/unit/lib/cfn_language_extensions/test_models.py new file mode 100644 index 00000000000..143fdbe58b1 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_models.py @@ -0,0 +1,285 @@ +""" +Unit tests for the models module. + +Tests for ResolutionMode enum, PseudoParameterValues, ParsedTemplate, +and TemplateProcessingContext dataclasses. +""" + +import pytest +from dataclasses import fields + +from samcli.lib.cfn_language_extensions import ( + ResolutionMode, + PseudoParameterValues, + ParsedTemplate, + TemplateProcessingContext, +) + + +class TestResolutionMode: + """Tests for ResolutionMode enum.""" + + def test_full_mode_value(self): + """ResolutionMode.FULL should have value 'full'.""" + assert ResolutionMode.FULL.value == "full" + + def test_partial_mode_value(self): + """ResolutionMode.PARTIAL should have value 'partial'.""" + assert ResolutionMode.PARTIAL.value == "partial" + + def test_enum_members(self): + """ResolutionMode should have exactly FULL and PARTIAL members.""" + members = list(ResolutionMode) + assert len(members) == 2 + assert ResolutionMode.FULL in members + assert ResolutionMode.PARTIAL in members + + +class TestPseudoParameterValues: + """Tests for PseudoParameterValues dataclass.""" + + def test_required_fields(self): + """PseudoParameterValues requires region and account_id.""" + pseudo = PseudoParameterValues(region="us-east-1", account_id="123456789012") + assert pseudo.region == "us-east-1" + assert pseudo.account_id == "123456789012" + + def test_optional_fields_default_to_none(self): + """Optional fields should default to None.""" + pseudo = PseudoParameterValues(region="us-west-2", account_id="123456789012") + assert pseudo.stack_id is None + assert pseudo.stack_name is None + assert pseudo.notification_arns is None + assert pseudo.partition is None + assert pseudo.url_suffix is None + + def test_all_fields_can_be_set(self): + """All fields can be explicitly set.""" + pseudo = PseudoParameterValues( + region="eu-west-1", + account_id="987654321098", + stack_id="arn:aws:cloudformation:eu-west-1:987654321098:stack/my-stack/guid", + stack_name="my-stack", + notification_arns=["arn:aws:sns:eu-west-1:987654321098:my-topic"], + partition="aws", + url_suffix="amazonaws.com", + ) + assert pseudo.region == "eu-west-1" + assert pseudo.account_id == "987654321098" + assert pseudo.stack_id == "arn:aws:cloudformation:eu-west-1:987654321098:stack/my-stack/guid" + assert pseudo.stack_name == "my-stack" + assert pseudo.notification_arns == ["arn:aws:sns:eu-west-1:987654321098:my-topic"] + assert pseudo.partition == "aws" + assert pseudo.url_suffix == "amazonaws.com" + + def test_notification_arns_can_be_empty_list(self): + """notification_arns can be an empty list.""" + pseudo = PseudoParameterValues(region="us-east-1", account_id="123456789012", notification_arns=[]) + assert pseudo.notification_arns == [] + + def test_supports_aws_region_requirement(self): + """Validates requirement 9.1: accepts AWS::Region value.""" + pseudo = PseudoParameterValues(region="ap-southeast-1", account_id="123456789012") + assert pseudo.region == "ap-southeast-1" + + def test_supports_aws_account_id_requirement(self): + """Validates requirement 9.1: accepts AWS::AccountId value.""" + pseudo = PseudoParameterValues(region="us-east-1", account_id="111122223333") + assert pseudo.account_id == "111122223333" + + def test_supports_aws_stack_name_requirement(self): + """Validates requirement 9.1: accepts AWS::StackName value.""" + pseudo = PseudoParameterValues(region="us-east-1", account_id="123456789012", stack_name="production-stack") + assert pseudo.stack_name == "production-stack" + + def test_supports_aws_stack_id_requirement(self): + """Validates requirement 9.1: accepts AWS::StackId value.""" + pseudo = PseudoParameterValues( + region="us-east-1", + account_id="123456789012", + stack_id="arn:aws:cloudformation:us-east-1:123456789012:stack/test/guid", + ) + assert pseudo.stack_id == "arn:aws:cloudformation:us-east-1:123456789012:stack/test/guid" + + def test_supports_aws_notification_arns_requirement(self): + """Validates requirement 9.1: accepts AWS::NotificationARNs value.""" + arns = ["arn:aws:sns:us-east-1:123456789012:topic1", "arn:aws:sns:us-east-1:123456789012:topic2"] + pseudo = PseudoParameterValues(region="us-east-1", account_id="123456789012", notification_arns=arns) + assert pseudo.notification_arns == arns + + +class TestParsedTemplate: + """Tests for ParsedTemplate dataclass.""" + + def test_default_values(self): + """All fields should have sensible defaults.""" + parsed = ParsedTemplate() + assert parsed.aws_template_format_version is None + assert parsed.description is None + assert parsed.parameters == {} + assert parsed.mappings == {} + assert parsed.conditions == {} + assert parsed.resources is None # Resources can be None for validation + assert parsed.outputs == {} + assert parsed.transform is None + + def test_all_fields_can_be_set(self): + """All fields can be explicitly set.""" + parsed = ParsedTemplate( + aws_template_format_version="2010-09-09", + description="Test template", + parameters={"Env": {"Type": "String"}}, + mappings={"RegionMap": {"us-east-1": {"AMI": "ami-123"}}}, + conditions={"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}}, + resources={"Bucket": {"Type": "AWS::S3::Bucket"}}, + outputs={"BucketName": {"Value": {"Ref": "Bucket"}}}, + transform="AWS::Serverless-2016-10-31", + ) + assert parsed.aws_template_format_version == "2010-09-09" + assert parsed.description == "Test template" + assert parsed.parameters == {"Env": {"Type": "String"}} + assert parsed.mappings == {"RegionMap": {"us-east-1": {"AMI": "ami-123"}}} + assert parsed.conditions == {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}} + assert parsed.resources == {"Bucket": {"Type": "AWS::S3::Bucket"}} + assert parsed.outputs == {"BucketName": {"Value": {"Ref": "Bucket"}}} + assert parsed.transform == "AWS::Serverless-2016-10-31" + + def test_transform_can_be_list(self): + """Transform can be a list of transforms.""" + parsed = ParsedTemplate(transform=["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"]) + assert parsed.transform == ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"] + + def test_dict_fields_are_independent(self): + """Each instance should have independent dict fields.""" + parsed1 = ParsedTemplate() + parsed2 = ParsedTemplate() + + parsed1.parameters["Key"] = "Value" + + assert "Key" not in parsed2.parameters + + +class TestTemplateProcessingContext: + """Tests for TemplateProcessingContext dataclass.""" + + def test_required_fragment_field(self): + """TemplateProcessingContext requires fragment.""" + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + assert context.fragment == {"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + + def test_default_values(self): + """Optional fields should have sensible defaults.""" + context = TemplateProcessingContext(fragment={}) + assert context.parameter_values == {} + assert context.pseudo_parameters is None + assert context.resolution_mode == ResolutionMode.PARTIAL + assert context.parsed_template is None + assert context.resolved_conditions == {} + assert context.request_id == "" + + def test_all_fields_can_be_set(self): + """All fields can be explicitly set.""" + pseudo = PseudoParameterValues(region="us-east-1", account_id="123456789012") + parsed = ParsedTemplate(resources={"Bucket": {"Type": "AWS::S3::Bucket"}}) + + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"Env": "prod"}, + pseudo_parameters=pseudo, + resolution_mode=ResolutionMode.FULL, + parsed_template=parsed, + resolved_conditions={"IsProd": True}, + request_id="req-123", + ) + + assert context.fragment == {"Resources": {}} + assert context.parameter_values == {"Env": "prod"} + assert context.pseudo_parameters is pseudo + assert context.resolution_mode == ResolutionMode.FULL + assert context.parsed_template is parsed + assert context.resolved_conditions == {"IsProd": True} + assert context.request_id == "req-123" + + def test_default_resolution_mode_is_partial(self): + """Default resolution mode should be PARTIAL for SAM integration.""" + context = TemplateProcessingContext(fragment={}) + assert context.resolution_mode == ResolutionMode.PARTIAL + + def test_provides_template_processing_context_class(self): + """Validates requirement 12.2: provides TemplateProcessingContext class.""" + # The class exists and can be instantiated + context = TemplateProcessingContext(fragment={"Resources": {}}, parameter_values={"Key": "Value"}) + assert isinstance(context, TemplateProcessingContext) + + def test_context_accepts_pseudo_parameters(self): + """Validates requirement 9.1: context accepts pseudo-parameter values.""" + pseudo = PseudoParameterValues( + region="us-west-2", + account_id="123456789012", + stack_name="my-stack", + stack_id="arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid", + notification_arns=["arn:aws:sns:us-west-2:123456789012:topic"], + ) + context = TemplateProcessingContext(fragment={}, pseudo_parameters=pseudo) + assert context.pseudo_parameters.region == "us-west-2" + assert context.pseudo_parameters.account_id == "123456789012" + assert context.pseudo_parameters.stack_name == "my-stack" + assert context.pseudo_parameters.stack_id == "arn:aws:cloudformation:us-west-2:123456789012:stack/my-stack/guid" + assert context.pseudo_parameters.notification_arns == ["arn:aws:sns:us-west-2:123456789012:topic"] + + def test_dict_fields_are_independent(self): + """Each instance should have independent dict fields.""" + context1 = TemplateProcessingContext(fragment={}) + context2 = TemplateProcessingContext(fragment={}) + + context1.parameter_values["Key"] = "Value" + context1.resolved_conditions["Cond"] = True + + assert "Key" not in context2.parameter_values + assert "Cond" not in context2.resolved_conditions + + def test_fragment_is_mutable(self): + """Fragment should be mutable during processing.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + context.fragment["Resources"]["NewResource"] = {"Type": "AWS::S3::Bucket"} + assert "NewResource" in context.fragment["Resources"] + + def test_parsed_template_can_be_set_during_processing(self): + """parsed_template can be set during processing.""" + context = TemplateProcessingContext(fragment={}) + assert context.parsed_template is None + + context.parsed_template = ParsedTemplate(resources={"Bucket": {"Type": "AWS::S3::Bucket"}}) + assert context.parsed_template is not None + assert "Bucket" in context.parsed_template.resources + + +class TestModuleExports: + """Tests for module exports from __init__.py.""" + + def test_resolution_mode_exported(self): + """ResolutionMode should be exported from package.""" + from samcli.lib.cfn_language_extensions import ResolutionMode + + assert ResolutionMode.FULL.value == "full" + + def test_pseudo_parameter_values_exported(self): + """PseudoParameterValues should be exported from package.""" + from samcli.lib.cfn_language_extensions import PseudoParameterValues + + pseudo = PseudoParameterValues(region="us-east-1", account_id="123") + assert pseudo.region == "us-east-1" + + def test_parsed_template_exported(self): + """ParsedTemplate should be exported from package.""" + from samcli.lib.cfn_language_extensions import ParsedTemplate + + parsed = ParsedTemplate() + assert parsed.resources is None # Resources can be None for validation + + def test_template_processing_context_exported(self): + """TemplateProcessingContext should be exported from package.""" + from samcli.lib.cfn_language_extensions import TemplateProcessingContext + + context = TemplateProcessingContext(fragment={}) + assert context.fragment == {} diff --git a/tests/unit/lib/cfn_language_extensions/test_package.py b/tests/unit/lib/cfn_language_extensions/test_package.py new file mode 100644 index 00000000000..c18c0333533 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_package.py @@ -0,0 +1,33 @@ +""" +Tests for package structure and basic imports. +""" + +import pytest + + +class TestPackageStructure: + """Tests to verify the package is properly structured.""" + + def test_package_imports(self): + """Test that the main package can be imported.""" + import samcli.lib.cfn_language_extensions + + assert samcli.lib.cfn_language_extensions is not None + + def test_package_has_version(self): + """Test that the package has a version string.""" + from samcli.lib.cfn_language_extensions import __version__ + + assert __version__ is not None + assert isinstance(__version__, str) + assert len(__version__) > 0 + + def test_version_format(self): + """Test that version follows semantic versioning format.""" + from samcli.lib.cfn_language_extensions import __version__ + + parts = __version__.split(".") + assert len(parts) >= 2, "Version should have at least major.minor" + # Each part should be numeric (possibly with pre-release suffix) + assert parts[0].isdigit(), "Major version should be numeric" + assert parts[1].isdigit(), "Minor version should be numeric" diff --git a/tests/unit/lib/cfn_language_extensions/test_parsing.py b/tests/unit/lib/cfn_language_extensions/test_parsing.py new file mode 100644 index 00000000000..d3e33871215 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_parsing.py @@ -0,0 +1,315 @@ +""" +Unit tests for the TemplateParsingProcessor. + +Tests for template parsing and validation. +Validates requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6. +""" + +import pytest + +from samcli.lib.cfn_language_extensions import ( + TemplateParsingProcessor, + TemplateProcessingContext, + InvalidTemplateException, + ParsedTemplate, +) + + +class TestTemplateParsingProcessor: + """Tests for TemplateParsingProcessor class.""" + + def test_parses_valid_template(self): + """Requirement 2.1: Parse valid template into structured Template object.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Test template", + "Parameters": {"Env": {"Type": "String"}}, + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-123"}}}, + "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}}, + "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}, + "Outputs": {"BucketName": {"Value": {"Ref": "Bucket"}}}, + "Transform": "AWS::Serverless-2016-10-31", + } + ) + + processor.process_template(context) + + assert context.parsed_template is not None + assert context.parsed_template.aws_template_format_version == "2010-09-09" + assert context.parsed_template.description == "Test template" + assert context.parsed_template.parameters == {"Env": {"Type": "String"}} + assert context.parsed_template.mappings == {"RegionMap": {"us-east-1": {"AMI": "ami-123"}}} + assert context.parsed_template.conditions == {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}} + assert context.parsed_template.resources == {"Bucket": {"Type": "AWS::S3::Bucket"}} + assert context.parsed_template.outputs == {"BucketName": {"Value": {"Ref": "Bucket"}}} + assert context.parsed_template.transform == "AWS::Serverless-2016-10-31" + + def test_parses_minimal_template(self): + """Parse minimal template with only Resources section.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}}) + + processor.process_template(context) + + assert context.parsed_template is not None + assert context.parsed_template.resources == {"MyQueue": {"Type": "AWS::SQS::Queue"}} + + def test_null_resources_raises_exception(self): + """Requirement 2.2: Null Resources section raises InvalidTemplateException.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": None}) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "The Resources section must not be null" in str(exc_info.value) + + def test_missing_resources_raises_exception(self): + """Requirement 2.2: Missing Resources section raises InvalidTemplateException.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Parameters": {"Env": {"Type": "String"}}}) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "The Resources section must not be null" in str(exc_info.value) + + def test_null_output_raises_exception(self): + """Requirement 2.3: Null Output raises InvalidTemplateException.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}, "Outputs": {"NullOutput": None}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "[/Outputs/NullOutput] 'null' values are not allowed" in str(exc_info.value) + + def test_null_resource_raises_exception(self): + """Requirement 2.4: Null Resource raises InvalidTemplateException.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"ValidBucket": {"Type": "AWS::S3::Bucket"}, "NullResource": None}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "[/Resources/NullResource] resource definition is malformed" in str(exc_info.value) + + def test_initializes_missing_parameters_as_empty_dict(self): + """Requirement 2.6: Missing Parameters initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + processor.process_template(context) + + assert context.parsed_template.parameters == {} + + def test_initializes_missing_conditions_as_empty_dict(self): + """Requirement 2.6: Missing Conditions initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + processor.process_template(context) + + assert context.parsed_template.conditions == {} + + def test_initializes_missing_outputs_as_empty_dict(self): + """Requirement 2.6: Missing Outputs initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + processor.process_template(context) + + assert context.parsed_template.outputs == {} + + def test_initializes_missing_mappings_as_empty_dict(self): + """Requirement 2.6: Missing Mappings initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + processor.process_template(context) + + assert context.parsed_template.mappings == {} + + def test_initializes_null_parameters_as_empty_dict(self): + """Requirement 2.6: Null Parameters initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Parameters": None, "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + ) + + processor.process_template(context) + + assert context.parsed_template.parameters == {} + + def test_initializes_null_conditions_as_empty_dict(self): + """Requirement 2.6: Null Conditions initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Conditions": None, "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + ) + + processor.process_template(context) + + assert context.parsed_template.conditions == {} + + def test_initializes_null_outputs_as_empty_dict(self): + """Requirement 2.6: Null Outputs initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Outputs": None, "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + ) + + processor.process_template(context) + + assert context.parsed_template.outputs == {} + + def test_initializes_null_mappings_as_empty_dict(self): + """Requirement 2.6: Null Mappings initialized as empty dict.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Mappings": None, "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + ) + + processor.process_template(context) + + assert context.parsed_template.mappings == {} + + def test_preserves_transform_as_string(self): + """Transform can be a single string.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Transform": "AWS::Serverless-2016-10-31", "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + ) + + processor.process_template(context) + + assert context.parsed_template.transform == "AWS::Serverless-2016-10-31" + + def test_preserves_transform_as_list(self): + """Transform can be a list of transforms.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={ + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}, + } + ) + + processor.process_template(context) + + assert context.parsed_template.transform == ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"] + + def test_multiple_null_resources_reports_first(self): + """When multiple resources are null, report the first one found.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": { + "NullResource1": None, + "ValidResource": {"Type": "AWS::S3::Bucket"}, + "NullResource2": None, + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + # Should raise for one of the null resources + assert "resource definition is malformed" in str(exc_info.value) + + def test_multiple_null_outputs_reports_first(self): + """When multiple outputs are null, report the first one found.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}, + "Outputs": {"NullOutput1": None, "ValidOutput": {"Value": "test"}, "NullOutput2": None}, + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + # Should raise for one of the null outputs + assert "'null' values are not allowed" in str(exc_info.value) + + def test_empty_resources_is_valid(self): + """Empty Resources dict is valid (no resources defined).""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment={"Resources": {}}) + + processor.process_template(context) + + assert context.parsed_template.resources == {} + + def test_empty_outputs_is_valid(self): + """Empty Outputs dict is valid.""" + processor = TemplateParsingProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}, "Outputs": {}} + ) + + processor.process_template(context) + + assert context.parsed_template.outputs == {} + + def test_implements_template_processor_protocol(self): + """TemplateParsingProcessor should implement TemplateProcessor protocol.""" + from samcli.lib.cfn_language_extensions import TemplateProcessor + + processor = TemplateParsingProcessor() + assert isinstance(processor, TemplateProcessor) + + +class TestTemplateParsingProcessorIntegration: + """Integration tests for TemplateParsingProcessor with pipeline.""" + + def test_works_in_pipeline(self): + """TemplateParsingProcessor should work in a ProcessingPipeline.""" + from samcli.lib.cfn_language_extensions import ProcessingPipeline + + processor = TemplateParsingProcessor() + pipeline = ProcessingPipeline([processor]) + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + result = pipeline.process_template(context) + + assert context.parsed_template is not None + assert context.parsed_template.resources == {"Bucket": {"Type": "AWS::S3::Bucket"}} + assert result == {"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}} + + def test_exception_propagates_through_pipeline(self): + """InvalidTemplateException should propagate through pipeline.""" + from samcli.lib.cfn_language_extensions import ProcessingPipeline + + processor = TemplateParsingProcessor() + pipeline = ProcessingPipeline([processor]) + context = TemplateProcessingContext(fragment={"Resources": None}) + + with pytest.raises(InvalidTemplateException) as exc_info: + pipeline.process_template(context) + + assert "The Resources section must not be null" in str(exc_info.value) + + +class TestModuleExports: + """Tests for module exports.""" + + def test_template_parsing_processor_exported_from_package(self): + """TemplateParsingProcessor should be exported from main package.""" + from samcli.lib.cfn_language_extensions import TemplateParsingProcessor + + assert TemplateParsingProcessor is not None + + def test_template_parsing_processor_exported_from_processors(self): + """TemplateParsingProcessor should be exported from processors module.""" + from samcli.lib.cfn_language_extensions.processors import TemplateParsingProcessor + + assert TemplateParsingProcessor is not None diff --git a/tests/unit/lib/cfn_language_extensions/test_parsing_properties.py b/tests/unit/lib/cfn_language_extensions/test_parsing_properties.py new file mode 100644 index 00000000000..0fc7deb58c5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_parsing_properties.py @@ -0,0 +1,360 @@ +""" +Parametrized tests for the TemplateParsingProcessor. + +These tests validate universal properties that should hold across representative inputs. + +# Feature: cfn-language-extensions-python, Property 3: Template Parsing Round-Trip +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions import ( + TemplateParsingProcessor, + TemplateProcessingContext, + ParsedTemplate, +) + + +def parsed_template_to_dict(parsed: ParsedTemplate) -> Dict[str, Any]: + """ + Convert a ParsedTemplate back to a dictionary representation. + + This function reconstructs the original template dictionary from + the parsed template, preserving only non-empty/non-None sections. + """ + result: Dict[str, Any] = {} + + if parsed.aws_template_format_version is not None: + result["AWSTemplateFormatVersion"] = parsed.aws_template_format_version + + if parsed.description is not None: + result["Description"] = parsed.description + + if parsed.parameters: + result["Parameters"] = parsed.parameters + + if parsed.mappings: + result["Mappings"] = parsed.mappings + + if parsed.conditions: + result["Conditions"] = parsed.conditions + + if parsed.resources is not None: + result["Resources"] = parsed.resources + + if parsed.outputs: + result["Outputs"] = parsed.outputs + + if parsed.transform is not None: + result["Transform"] = parsed.transform + + return result + + +# ============================================================================= +# Concrete template examples for parametrized tests +# ============================================================================= + +MINIMAL_TEMPLATE = { + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {}, + } + }, +} + +FULL_TEMPLATE = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "A full test template", + "Parameters": { + "Env": {"Type": "String"}, + }, + "Mappings": { + "RegionMap": { + "useast1": {"AMI": "ami-12345"}, + }, + }, + "Conditions": { + "IsProd": {"Fn::Equals": ["prod", "prod"]}, + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "my-bucket"}, + }, + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": {}, + }, + }, + "Outputs": { + "BucketArn": {"Value": "arn:aws:s3:::my-bucket"}, + }, + "Transform": "AWS::Serverless-2016-10-31", +} + +TEMPLATE_WITH_TRANSFORM_LIST = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "Fn": { + "Type": "AWS::Lambda::Function", + "Properties": {"Runtime": "python3.12"}, + }, + }, + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], +} + + +# ============================================================================= +# Parametrized Tests +# ============================================================================= + + +class TestTemplateParsingRoundTrip: + """ + Property 3: Template Parsing Round-Trip + + For any valid CloudFormation template dictionary, parsing it into a + ParsedTemplate and converting back to a dictionary SHALL produce an + equivalent structure. + + **Validates: Requirements 2.1** + """ + + @pytest.mark.parametrize( + "template", + [MINIMAL_TEMPLATE, FULL_TEMPLATE, TEMPLATE_WITH_TRANSFORM_LIST], + ids=["minimal", "full", "transform-list"], + ) + def test_parsing_round_trip_preserves_structure(self, template: dict): + """ + # Feature: cfn-language-extensions-python, Property 3: Template Parsing Round-Trip + **Validates: Requirements 2.1** + """ + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context) + + assert context.parsed_template is not None + reconstructed = parsed_template_to_dict(context.parsed_template) + + assert reconstructed.get("AWSTemplateFormatVersion") == template.get("AWSTemplateFormatVersion") + assert reconstructed.get("Description") == template.get("Description") + assert reconstructed.get("Parameters", {}) == template.get("Parameters", {}) + assert reconstructed.get("Mappings", {}) == template.get("Mappings", {}) + assert reconstructed.get("Conditions", {}) == template.get("Conditions", {}) + assert reconstructed.get("Resources") == template.get("Resources") + assert reconstructed.get("Outputs", {}) == template.get("Outputs", {}) + assert reconstructed.get("Transform") == template.get("Transform") + + @pytest.mark.parametrize( + "template", + [MINIMAL_TEMPLATE, FULL_TEMPLATE, TEMPLATE_WITH_TRANSFORM_LIST], + ids=["minimal", "full", "transform-list"], + ) + def test_parsed_template_fields_match_input(self, template: dict): + """ + Verify that each field in ParsedTemplate correctly captures the + corresponding section from the input template. + + **Validates: Requirements 2.1** + """ + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context) + parsed = context.parsed_template + assert parsed is not None + + assert parsed.aws_template_format_version == template.get("AWSTemplateFormatVersion") + assert parsed.description == template.get("Description") + assert parsed.resources == template.get("Resources") + assert parsed.transform == template.get("Transform") + assert parsed.parameters == template.get("Parameters", {}) + assert parsed.mappings == template.get("Mappings", {}) + assert parsed.conditions == template.get("Conditions", {}) + assert parsed.outputs == template.get("Outputs", {}) + + @pytest.mark.parametrize( + "template", + [MINIMAL_TEMPLATE, FULL_TEMPLATE, TEMPLATE_WITH_TRANSFORM_LIST], + ids=["minimal", "full", "transform-list"], + ) + def test_parsing_is_idempotent(self, template: dict): + """ + Parsing a template twice should produce the same ParsedTemplate. + + **Validates: Requirements 2.1** + """ + processor = TemplateParsingProcessor() + context1 = TemplateProcessingContext(fragment=template.copy()) + context2 = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context1) + processor.process_template(context2) + + parsed1 = context1.parsed_template + parsed2 = context2.parsed_template + assert parsed1 is not None + assert parsed2 is not None + + assert parsed1.aws_template_format_version == parsed2.aws_template_format_version + assert parsed1.description == parsed2.description + assert parsed1.parameters == parsed2.parameters + assert parsed1.mappings == parsed2.mappings + assert parsed1.conditions == parsed2.conditions + assert parsed1.resources == parsed2.resources + assert parsed1.outputs == parsed2.outputs + assert parsed1.transform == parsed2.transform + + +class TestMissingSectionsInitialization: + """ + Property 4: Missing Sections Initialization + + For any template missing optional sections (Parameters, Conditions, Outputs, + Mappings), the parser SHALL initialize them as empty dictionaries. + + **Validates: Requirements 2.6** + """ + + @pytest.mark.parametrize( + "template,missing_sections", + [ + ( + {"Resources": {"B": {"Type": "AWS::S3::Bucket", "Properties": {}}}}, + ["Parameters", "Mappings", "Conditions", "Outputs"], + ), + ( + { + "Resources": {"B": {"Type": "AWS::S3::Bucket", "Properties": {}}}, + "Parameters": {"P": {"Type": "String"}}, + }, + ["Mappings", "Conditions", "Outputs"], + ), + ( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": {"B": {"Type": "AWS::S3::Bucket", "Properties": {}}}, + "Outputs": {"O": {"Value": "val"}}, + }, + ["Parameters", "Mappings", "Conditions"], + ), + ], + ids=["all-missing", "only-params-present", "only-outputs-present"], + ) + def test_missing_sections_initialized_as_empty_dicts(self, template: dict, missing_sections: list): + """ + # Feature: cfn-language-extensions-python, Property 4: Missing Sections Initialization + **Validates: Requirements 2.6** + """ + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context) + parsed = context.parsed_template + assert parsed is not None + + assert isinstance(parsed.parameters, dict) + assert isinstance(parsed.mappings, dict) + assert isinstance(parsed.conditions, dict) + assert isinstance(parsed.outputs, dict) + + if "Parameters" in missing_sections: + assert parsed.parameters == {} + if "Mappings" in missing_sections: + assert parsed.mappings == {} + if "Conditions" in missing_sections: + assert parsed.conditions == {} + if "Outputs" in missing_sections: + assert parsed.outputs == {} + + @pytest.mark.parametrize( + "resources", + [ + {"Bucket": {"Type": "AWS::S3::Bucket", "Properties": {}}}, + {"Queue": {"Type": "AWS::SQS::Queue", "Properties": {"DelaySeconds": 5}}}, + { + "Topic": {"Type": "AWS::SNS::Topic", "Properties": {}}, + "Sub": {"Type": "AWS::SNS::Subscription", "Properties": {}}, + }, + ], + ids=["single-bucket", "queue-with-props", "two-resources"], + ) + def test_minimal_template_initializes_all_optional_sections(self, resources: dict): + """ + # Feature: cfn-language-extensions-python, Property 4: Missing Sections Initialization + **Validates: Requirements 2.6** + """ + template = {"Resources": resources} + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context) + parsed = context.parsed_template + assert parsed is not None + + assert parsed.parameters == {} + assert parsed.mappings == {} + assert parsed.conditions == {} + assert parsed.outputs == {} + assert parsed.resources == resources + + @pytest.mark.parametrize( + "include_params,include_mappings,include_conditions,include_outputs", + [ + (False, False, False, False), + (True, False, True, False), + (True, True, True, True), + ], + ids=["none-present", "params-and-conditions", "all-present"], + ) + def test_any_combination_of_missing_sections( + self, + include_params: bool, + include_mappings: bool, + include_conditions: bool, + include_outputs: bool, + ): + """ + # Feature: cfn-language-extensions-python, Property 4: Missing Sections Initialization + **Validates: Requirements 2.6** + """ + resources = {"Res": {"Type": "AWS::S3::Bucket", "Properties": {}}} + template: Dict[str, Any] = {"Resources": resources} + + expected_params: Dict[str, Any] = {} + expected_mappings: Dict[str, Any] = {} + expected_conditions: Dict[str, Any] = {} + expected_outputs: Dict[str, Any] = {} + + if include_params: + expected_params = {"TestParam": {"Type": "String"}} + template["Parameters"] = expected_params + + if include_mappings: + expected_mappings = {"TestMap": {"key1": {"subkey": "value"}}} + template["Mappings"] = expected_mappings + + if include_conditions: + expected_conditions = {"TestCondition": {"Fn::Equals": ["a", "b"]}} + template["Conditions"] = expected_conditions + + if include_outputs: + expected_outputs = {"TestOutput": {"Value": "test-value"}} + template["Outputs"] = expected_outputs + + processor = TemplateParsingProcessor() + context = TemplateProcessingContext(fragment=template.copy()) + + processor.process_template(context) + parsed = context.parsed_template + assert parsed is not None + + assert parsed.parameters == expected_params + assert parsed.mappings == expected_mappings + assert parsed.conditions == expected_conditions + assert parsed.outputs == expected_outputs diff --git a/tests/unit/lib/cfn_language_extensions/test_pipeline.py b/tests/unit/lib/cfn_language_extensions/test_pipeline.py new file mode 100644 index 00000000000..7b913d2a9e3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_pipeline.py @@ -0,0 +1,455 @@ +""" +Unit tests for the pipeline module. + +Tests for TemplateProcessor protocol and ProcessingPipeline class. +Validates requirements 1.1, 1.2, 1.3, 1.4. +""" + +import pytest +from typing import List, Optional + +from samcli.lib.cfn_language_extensions import ( + TemplateProcessor, + ProcessingPipeline, + TemplateProcessingContext, + InvalidTemplateException, +) + + +class MockProcessor: + """A mock processor that records calls and can modify the context.""" + + def __init__(self, name: str, modification: Optional[dict] = None, should_raise: Optional[Exception] = None): + """ + Initialize the mock processor. + + Args: + name: Identifier for this processor. + modification: Dict to merge into context.fragment when processed. + should_raise: Exception to raise when process_template is called. + """ + self.name = name + self.modification = modification or {} + self.should_raise = should_raise + self.call_count = 0 + self.received_contexts: List[TemplateProcessingContext] = [] + + def process_template(self, context: TemplateProcessingContext) -> None: + """Process the template by recording the call and optionally modifying context.""" + self.call_count += 1 + self.received_contexts.append(context) + + if self.should_raise: + raise self.should_raise + + # Apply modifications to the fragment + context.fragment.update(self.modification) + + +class TestTemplateProcessorProtocol: + """Tests for TemplateProcessor protocol.""" + + def test_mock_processor_implements_protocol(self): + """MockProcessor should implement TemplateProcessor protocol.""" + processor = MockProcessor("test") + assert isinstance(processor, TemplateProcessor) + + def test_protocol_requires_process_template_method(self): + """TemplateProcessor protocol requires process_template method.""" + + class ValidProcessor: + def process_template(self, context: TemplateProcessingContext) -> None: + pass + + processor = ValidProcessor() + assert isinstance(processor, TemplateProcessor) + + def test_class_without_method_is_not_processor(self): + """Class without process_template is not a TemplateProcessor.""" + + class InvalidProcessor: + pass + + processor = InvalidProcessor() + assert not isinstance(processor, TemplateProcessor) + + def test_class_with_wrong_signature_is_still_protocol_match(self): + """Protocol only checks method existence, not signature at runtime.""" + + # Note: Protocol runtime checking only verifies method existence + class ProcessorWithDifferentSignature: + def process_template(self): # Missing context parameter + pass + + processor = ProcessorWithDifferentSignature() + # Runtime protocol check only verifies method exists + assert isinstance(processor, TemplateProcessor) + + +class TestProcessingPipeline: + """Tests for ProcessingPipeline class.""" + + def test_empty_pipeline_returns_fragment(self): + """Empty pipeline should return the original fragment unchanged.""" + pipeline = ProcessingPipeline([]) + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}}) + + result = pipeline.process_template(context) + + assert result == {"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket"}}} + + def test_single_processor_is_executed(self): + """Single processor should be executed.""" + processor = MockProcessor("single", modification={"Processed": True}) + pipeline = ProcessingPipeline([processor]) + context = TemplateProcessingContext(fragment={"Resources": {}}) + + result = pipeline.process_template(context) + + assert processor.call_count == 1 + assert result["Processed"] is True + + def test_processors_executed_in_order(self): + """Requirement 1.1: Processors should be executed in order.""" + execution_order = [] + + class OrderTrackingProcessor: + def __init__(self, name: str): + self.name = name + + def process_template(self, context: TemplateProcessingContext) -> None: + execution_order.append(self.name) + + processors = [ + OrderTrackingProcessor("first"), + OrderTrackingProcessor("second"), + OrderTrackingProcessor("third"), + ] + pipeline = ProcessingPipeline(processors) + context = TemplateProcessingContext(fragment={}) + + pipeline.process_template(context) + + assert execution_order == ["first", "second", "third"] + + def test_context_passed_through_processors(self): + """Requirement 1.2: Same context should be passed through each processor.""" + processor1 = MockProcessor("first") + processor2 = MockProcessor("second") + processor3 = MockProcessor("third") + + pipeline = ProcessingPipeline([processor1, processor2, processor3]) + context = TemplateProcessingContext(fragment={"Resources": {}}) + + pipeline.process_template(context) + + # All processors should receive the same context object + assert processor1.received_contexts[0] is context + assert processor2.received_contexts[0] is context + assert processor3.received_contexts[0] is context + + def test_modifications_accumulate(self): + """Modifications from each processor should accumulate in context.""" + processor1 = MockProcessor("first", modification={"Step1": "done"}) + processor2 = MockProcessor("second", modification={"Step2": "done"}) + processor3 = MockProcessor("third", modification={"Step3": "done"}) + + pipeline = ProcessingPipeline([processor1, processor2, processor3]) + context = TemplateProcessingContext(fragment={"Resources": {}}) + + result = pipeline.process_template(context) + + assert result["Step1"] == "done" + assert result["Step2"] == "done" + assert result["Step3"] == "done" + assert result["Resources"] == {} + + def test_returns_processed_fragment(self): + """Requirement 1.3: Should return the processed template fragment.""" + processor = MockProcessor("modifier", modification={"NewKey": "NewValue"}) + pipeline = ProcessingPipeline([processor]) + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + result = pipeline.process_template(context) + + assert result is context.fragment + assert result["NewKey"] == "NewValue" + assert result["Resources"]["Bucket"]["Type"] == "AWS::S3::Bucket" + + def test_exception_propagation_stops_pipeline(self): + """Requirement 1.4: Exception should stop pipeline and propagate.""" + processor1 = MockProcessor("first") + processor2 = MockProcessor("failing", should_raise=InvalidTemplateException("Test error")) + processor3 = MockProcessor("third") + + pipeline = ProcessingPipeline([processor1, processor2, processor3]) + context = TemplateProcessingContext(fragment={}) + + with pytest.raises(InvalidTemplateException) as exc_info: + pipeline.process_template(context) + + assert str(exc_info.value) == "Test error" + assert processor1.call_count == 1 + assert processor2.call_count == 1 + assert processor3.call_count == 0 # Should not be executed + + def test_exception_propagation_preserves_exception_type(self): + """Requirement 1.4: Original exception type should be preserved.""" + + class CustomException(Exception): + pass + + processor = MockProcessor("failing", should_raise=CustomException("Custom error")) + pipeline = ProcessingPipeline([processor]) + context = TemplateProcessingContext(fragment={}) + + with pytest.raises(CustomException): + pipeline.process_template(context) + + def test_exception_in_first_processor(self): + """Exception in first processor should prevent all subsequent processors.""" + processor1 = MockProcessor("failing", should_raise=InvalidTemplateException("First processor failed")) + processor2 = MockProcessor("second") + processor3 = MockProcessor("third") + + pipeline = ProcessingPipeline([processor1, processor2, processor3]) + context = TemplateProcessingContext(fragment={}) + + with pytest.raises(InvalidTemplateException): + pipeline.process_template(context) + + assert processor1.call_count == 1 + assert processor2.call_count == 0 + assert processor3.call_count == 0 + + def test_exception_in_last_processor(self): + """Exception in last processor should still propagate.""" + processor1 = MockProcessor("first", modification={"Step1": "done"}) + processor2 = MockProcessor("second", modification={"Step2": "done"}) + processor3 = MockProcessor("failing", should_raise=InvalidTemplateException("Last processor failed")) + + pipeline = ProcessingPipeline([processor1, processor2, processor3]) + context = TemplateProcessingContext(fragment={}) + + with pytest.raises(InvalidTemplateException) as exc_info: + pipeline.process_template(context) + + assert "Last processor failed" in str(exc_info.value) + assert processor1.call_count == 1 + assert processor2.call_count == 1 + assert processor3.call_count == 1 + # Modifications from successful processors should still be in context + assert context.fragment["Step1"] == "done" + assert context.fragment["Step2"] == "done" + + def test_processors_property_returns_copy(self): + """processors property should return a copy of the processor list.""" + processor1 = MockProcessor("first") + processor2 = MockProcessor("second") + pipeline = ProcessingPipeline([processor1, processor2]) + + processors = pipeline.processors + + assert len(processors) == 2 + assert processors[0] is processor1 + assert processors[1] is processor2 + + # Modifying returned list should not affect pipeline + processors.append(MockProcessor("third")) + assert len(pipeline.processors) == 2 + + +class TestProcessingPipelineIntegration: + """Integration tests for ProcessingPipeline with realistic scenarios.""" + + def test_pipeline_with_context_state_modification(self): + """Processors can modify context state beyond just fragment.""" + + class ParsedTemplateProcessor: + def process_template(self, context: TemplateProcessingContext) -> None: + from samcli.lib.cfn_language_extensions import ParsedTemplate + + context.parsed_template = ParsedTemplate(resources=context.fragment.get("Resources", {})) + + class ConditionProcessor: + def process_template(self, context: TemplateProcessingContext) -> None: + context.resolved_conditions["IsProd"] = True + + pipeline = ProcessingPipeline([ParsedTemplateProcessor(), ConditionProcessor()]) + context = TemplateProcessingContext(fragment={"Resources": {"Bucket": {"Type": "AWS::S3::Bucket"}}}) + + pipeline.process_template(context) + + assert context.parsed_template is not None + assert "Bucket" in context.parsed_template.resources + assert context.resolved_conditions["IsProd"] is True + + def test_pipeline_with_parameter_values(self): + """Pipeline should work with parameter values in context.""" + + class ParameterResolver: + def process_template(self, context: TemplateProcessingContext) -> None: + env = context.parameter_values.get("Environment", "dev") + context.fragment["ResolvedEnv"] = env + + pipeline = ProcessingPipeline([ParameterResolver()]) + context = TemplateProcessingContext(fragment={"Resources": {}}, parameter_values={"Environment": "production"}) + + result = pipeline.process_template(context) + + assert result["ResolvedEnv"] == "production" + + def test_pipeline_preserves_original_fragment_structure(self): + """Pipeline should preserve nested structure in fragment.""" + + class NoOpProcessor: + def process_template(self, context: TemplateProcessingContext) -> None: + pass + + pipeline = ProcessingPipeline([NoOpProcessor()]) + original_fragment = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Test template", + "Parameters": {"Env": {"Type": "String", "Default": "dev"}}, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "my-bucket", "Tags": [{"Key": "Env", "Value": "dev"}]}, + } + }, + "Outputs": {"BucketArn": {"Value": {"Fn::GetAtt": ["Bucket", "Arn"]}}}, + } + context = TemplateProcessingContext(fragment=original_fragment) + + result = pipeline.process_template(context) + + assert result == original_fragment + + +class TestModuleExports: + """Tests for module exports.""" + + def test_template_processor_exported(self): + """TemplateProcessor should be exported from package.""" + from samcli.lib.cfn_language_extensions import TemplateProcessor + + assert TemplateProcessor is not None + + def test_processing_pipeline_exported(self): + """ProcessingPipeline should be exported from package.""" + from samcli.lib.cfn_language_extensions import ProcessingPipeline + + assert ProcessingPipeline is not None + + def test_can_create_pipeline_from_package_import(self): + """Should be able to create pipeline using package imports.""" + from samcli.lib.cfn_language_extensions import ( + ProcessingPipeline, + TemplateProcessingContext, + ) + + pipeline = ProcessingPipeline([]) + context = TemplateProcessingContext(fragment={"Resources": {}}) + result = pipeline.process_template(context) + + assert result == {"Resources": {}} + + +# ============================================================================= +# Parametrized Tests (replacing property-based tests) +# ============================================================================= + + +class TestPipelineProperties: + """Parametrized tests for ProcessingPipeline.""" + + @pytest.mark.parametrize( + "processor_names", + [ + [], + ["alpha", "beta", "gamma"], + ["p1", "p2", "p3", "p4", "p5"], + ], + ids=["empty", "three-processors", "five-processors"], + ) + def test_pipeline_execution_order_property(self, processor_names: List[str]): + """ + # Feature: cfn-language-extensions-python, Property 1: Pipeline Execution Order + + For any list of template processors, the ProcessingPipeline SHALL execute + them in the exact order they appear in the list. + + **Validates: Requirements 1.1, 1.2** + """ + execution_order: List[str] = [] + contexts_received: List[TemplateProcessingContext] = [] + + class OrderTrackingProcessor: + def __init__(self, name: str): + self.name = name + + def process_template(self, context: TemplateProcessingContext) -> None: + execution_order.append(self.name) + contexts_received.append(context) + + processors: List[TemplateProcessor] = [OrderTrackingProcessor(name) for name in processor_names] + pipeline = ProcessingPipeline(processors) + context = TemplateProcessingContext(fragment={"Resources": {}}) + + result = pipeline.process_template(context) + + assert execution_order == processor_names + for received_context in contexts_received: + assert received_context is context + assert result is context.fragment + + @pytest.mark.parametrize( + "num_processors,failing_index", + [ + (3, 0), + (5, 2), + (4, 3), + ], + ids=["fail-first-of-3", "fail-middle-of-5", "fail-last-of-4"], + ) + def test_pipeline_exception_propagation_property(self, num_processors: int, failing_index: int): + """ + # Feature: cfn-language-extensions-python, Property 2: Pipeline Exception Propagation + + For any pipeline where a processor raises InvalidTemplateException, + subsequent processors SHALL NOT be executed and the exception SHALL + propagate to the caller. + + **Validates: Requirements 1.4** + """ + executed_processors: List[int] = [] + + class TrackingProcessor: + def __init__(self, index: int, should_fail: bool): + self.index = index + self.should_fail = should_fail + + def process_template(self, context: TemplateProcessingContext) -> None: + executed_processors.append(self.index) + if self.should_fail: + raise InvalidTemplateException(f"Processor {self.index} failed") + + processors: List[TemplateProcessor] = [ + TrackingProcessor(i, should_fail=(i == failing_index)) for i in range(num_processors) + ] + pipeline = ProcessingPipeline(processors) + context = TemplateProcessingContext(fragment={"Resources": {}}) + + with pytest.raises(InvalidTemplateException) as exc_info: + pipeline.process_template(context) + + assert f"Processor {failing_index} failed" in str(exc_info.value) + + for i in range(failing_index): + assert i in executed_processors + assert failing_index in executed_processors + for i in range(failing_index + 1, num_processors): + assert i not in executed_processors + + expected_execution_order = list(range(failing_index + 1)) + assert executed_processors == expected_execution_order diff --git a/tests/unit/lib/cfn_language_extensions/test_resolvers_base.py b/tests/unit/lib/cfn_language_extensions/test_resolvers_base.py new file mode 100644 index 00000000000..9a03338ecc3 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_resolvers_base.py @@ -0,0 +1,800 @@ +""" +Unit tests for the IntrinsicFunctionResolver base class and related infrastructure. + +Tests cover: +- IntrinsicFunctionResolver base class pattern matching (can_resolve) +- IntrinsicFunctionResolver utility methods +- IntrinsicResolver orchestrator and resolver chain pattern +- Constants for resolvable and unresolvable intrinsics +- Partial resolution mode (preserving unresolvable references) +""" + +import pytest +from typing import Any, Dict + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ResolutionMode, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.resolvers.base import ( + IntrinsicFunctionResolver, + IntrinsicResolver, + RESOLVABLE_INTRINSICS, + UNRESOLVABLE_INTRINSICS, +) + + +class MockResolver(IntrinsicFunctionResolver): + """A mock resolver for testing that handles Fn::Mock.""" + + FUNCTION_NAMES = ["Fn::Mock"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return the arguments doubled if it's a number, otherwise as-is.""" + args = self.get_function_args(value) + if isinstance(args, (int, float)): + return args * 2 + return args + + +class MultiMockResolver(IntrinsicFunctionResolver): + """A mock resolver that handles multiple function names.""" + + FUNCTION_NAMES = ["Fn::MockA", "Fn::MockB"] + + def resolve(self, value: Dict[str, Any]) -> Any: + """Return the function name and args as a tuple.""" + fn_name = self.get_function_name(value) + args = self.get_function_args(value) + return (fn_name, args) + + +class TestIntrinsicFunctionResolverCanResolve: + """Tests for IntrinsicFunctionResolver.can_resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> MockResolver: + """Create a mock resolver for testing.""" + return MockResolver(context, None) + + def test_can_resolve_matching_function(self, resolver: MockResolver): + """Test that can_resolve returns True for matching function name.""" + value = {"Fn::Mock": [1, 2, 3]} + assert resolver.can_resolve(value) is True + + def test_can_resolve_non_matching_function(self, resolver: MockResolver): + """Test that can_resolve returns False for non-matching function name.""" + value = {"Fn::Other": [1, 2, 3]} + assert resolver.can_resolve(value) is False + + def test_can_resolve_non_dict(self, resolver: MockResolver): + """Test that can_resolve returns False for non-dict values.""" + assert resolver.can_resolve("string") is False + assert resolver.can_resolve(123) is False + assert resolver.can_resolve([1, 2, 3]) is False + assert resolver.can_resolve(None) is False + + def test_can_resolve_empty_dict(self, resolver: MockResolver): + """Test that can_resolve returns False for empty dict.""" + assert resolver.can_resolve({}) is False + + def test_can_resolve_multi_key_dict(self, resolver: MockResolver): + """Test that can_resolve returns False for dict with multiple keys.""" + value = {"Fn::Mock": [1, 2, 3], "extra": "key"} + assert resolver.can_resolve(value) is False + + def test_can_resolve_regular_dict(self, resolver: MockResolver): + """Test that can_resolve returns False for regular dict (not intrinsic).""" + value = {"Type": "AWS::S3::Bucket", "Properties": {}} + assert resolver.can_resolve(value) is False + + +class TestIntrinsicFunctionResolverUtilityMethods: + """Tests for IntrinsicFunctionResolver utility methods.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> MockResolver: + """Create a mock resolver for testing.""" + return MockResolver(context, None) + + def test_get_function_name(self, resolver: MockResolver): + """Test extracting function name from intrinsic function value.""" + value = {"Fn::Mock": [1, 2, 3]} + assert resolver.get_function_name(value) == "Fn::Mock" + + def test_get_function_args_list(self, resolver: MockResolver): + """Test extracting list arguments from intrinsic function value.""" + value = {"Fn::Mock": [1, 2, 3]} + assert resolver.get_function_args(value) == [1, 2, 3] + + def test_get_function_args_string(self, resolver: MockResolver): + """Test extracting string arguments from intrinsic function value.""" + value = {"Fn::Mock": "hello"} + assert resolver.get_function_args(value) == "hello" + + def test_get_function_args_dict(self, resolver: MockResolver): + """Test extracting dict arguments from intrinsic function value.""" + value = {"Fn::Mock": {"key": "value"}} + assert resolver.get_function_args(value) == {"key": "value"} + + +class TestIntrinsicFunctionResolverResolve: + """Tests for IntrinsicFunctionResolver.resolve() method.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> MockResolver: + """Create a mock resolver for testing.""" + return MockResolver(context, None) + + def test_resolve_returns_expected_value(self, resolver: MockResolver): + """Test that resolve returns the expected value.""" + value = {"Fn::Mock": 5} + assert resolver.resolve(value) == 10 # MockResolver doubles numbers + + def test_resolve_with_non_numeric_args(self, resolver: MockResolver): + """Test resolve with non-numeric arguments.""" + value = {"Fn::Mock": "hello"} + assert resolver.resolve(value) == "hello" + + +class TestMultiFunctionResolver: + """Tests for resolvers that handle multiple function names.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + @pytest.fixture + def resolver(self, context: TemplateProcessingContext) -> MultiMockResolver: + """Create a multi-function mock resolver for testing.""" + return MultiMockResolver(context, None) + + def test_can_resolve_first_function(self, resolver: MultiMockResolver): + """Test can_resolve for first function name.""" + value = {"Fn::MockA": "args"} + assert resolver.can_resolve(value) is True + + def test_can_resolve_second_function(self, resolver: MultiMockResolver): + """Test can_resolve for second function name.""" + value = {"Fn::MockB": "args"} + assert resolver.can_resolve(value) is True + + def test_resolve_identifies_function(self, resolver: MultiMockResolver): + """Test that resolve can identify which function was called.""" + value_a = {"Fn::MockA": "args_a"} + value_b = {"Fn::MockB": "args_b"} + + assert resolver.resolve(value_a) == ("Fn::MockA", "args_a") + assert resolver.resolve(value_b) == ("Fn::MockB", "args_b") + + +class TestIntrinsicResolver: + """Tests for IntrinsicResolver orchestrator.""" + + @pytest.fixture + def context(self) -> TemplateProcessingContext: + """Create a minimal template processing context.""" + return TemplateProcessingContext(fragment={"Resources": {}}) + + def test_register_resolver(self, context: TemplateProcessingContext): + """Test registering a resolver class.""" + orchestrator = IntrinsicResolver(context) + result = orchestrator.register_resolver(MockResolver) + + # Should return self for chaining + assert result is orchestrator + # Should have one resolver + assert len(orchestrator.resolvers) == 1 + assert isinstance(orchestrator.resolvers[0], MockResolver) + + def test_add_resolver(self, context: TemplateProcessingContext): + """Test adding a pre-instantiated resolver.""" + orchestrator = IntrinsicResolver(context) + resolver = MockResolver(context, orchestrator) + result = orchestrator.add_resolver(resolver) + + # Should return self for chaining + assert result is orchestrator + # Should have one resolver + assert len(orchestrator.resolvers) == 1 + assert orchestrator.resolvers[0] is resolver + + def test_method_chaining(self, context: TemplateProcessingContext): + """Test that register_resolver supports method chaining.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver).register_resolver(MultiMockResolver) + + assert len(orchestrator.resolvers) == 2 + + def test_resolve_value_with_matching_resolver(self, context: TemplateProcessingContext): + """Test resolve_value with a matching resolver.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver) + + result = orchestrator.resolve_value({"Fn::Mock": 5}) + assert result == 10 # MockResolver doubles numbers + + def test_resolve_value_with_no_matching_resolver(self, context: TemplateProcessingContext): + """Test resolve_value when no resolver matches.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver) + + # Fn::Other is not handled by MockResolver + result = orchestrator.resolve_value({"Fn::Other": 5}) + # Should return the dict as-is (with recursive processing) + assert result == {"Fn::Other": 5} + + def test_resolve_value_primitive(self, context: TemplateProcessingContext): + """Test resolve_value with primitive values.""" + orchestrator = IntrinsicResolver(context) + + assert orchestrator.resolve_value("string") == "string" + assert orchestrator.resolve_value(123) == 123 + assert orchestrator.resolve_value(True) is True + assert orchestrator.resolve_value(None) is None + + def test_resolve_value_list(self, context: TemplateProcessingContext): + """Test resolve_value with list containing intrinsics.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver) + + result = orchestrator.resolve_value([{"Fn::Mock": 5}, "plain", {"Fn::Mock": 10}]) + + assert result == [10, "plain", 20] + + def test_resolve_value_nested_dict(self, context: TemplateProcessingContext): + """Test resolve_value with nested dict containing intrinsics.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver) + + result = orchestrator.resolve_value({"outer": {"inner": {"Fn::Mock": 5}}}) + + assert result == {"outer": {"inner": 10}} + + def test_resolvers_property_returns_copy(self, context: TemplateProcessingContext): + """Test that resolvers property returns a copy.""" + orchestrator = IntrinsicResolver(context) + orchestrator.register_resolver(MockResolver) + + resolvers = orchestrator.resolvers + resolvers.append(None) # type: ignore[arg-type] # Intentionally testing with invalid type + + # Original should be unchanged + assert len(orchestrator.resolvers) == 1 + + +class TestIntrinsicConstants: + """Tests for intrinsic function constants.""" + + def test_resolvable_intrinsics_contains_expected_functions(self): + """Test that RESOLVABLE_INTRINSICS contains expected functions.""" + expected = { + "Fn::Length", + "Fn::ToJsonString", + "Fn::FindInMap", + "Fn::If", + "Fn::Sub", + "Fn::Join", + "Fn::Split", + "Fn::Select", + "Fn::Base64", + "Fn::Equals", + "Fn::And", + "Fn::Or", + "Fn::Not", + "Ref", + } + assert RESOLVABLE_INTRINSICS == expected + + def test_unresolvable_intrinsics_contains_expected_functions(self): + """Test that UNRESOLVABLE_INTRINSICS contains expected functions.""" + expected = { + "Fn::GetAtt", + "Fn::ImportValue", + "Fn::GetAZs", + "Fn::Cidr", + "Ref", + } + assert UNRESOLVABLE_INTRINSICS == expected + + def test_ref_in_both_sets(self): + """Test that Ref is in both sets (context-dependent resolution).""" + # Ref can be resolved for parameters/pseudo-parameters + # but must be preserved for resource references + assert "Ref" in RESOLVABLE_INTRINSICS + assert "Ref" in UNRESOLVABLE_INTRINSICS + + +class TestIntrinsicFunctionResolverAbstract: + """Tests for abstract behavior of IntrinsicFunctionResolver.""" + + def test_subclass_without_resolve_cannot_be_instantiated(self): + """Test that subclass without resolve() implementation cannot be instantiated.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + + # Create a subclass that doesn't override resolve + class IncompleteResolver(IntrinsicFunctionResolver): + FUNCTION_NAMES = ["Fn::Incomplete"] + + # Python's ABC prevents instantiation without implementing abstract methods + with pytest.raises(TypeError) as exc_info: + IncompleteResolver(context, None) + + assert "abstract" in str(exc_info.value).lower() + assert "resolve" in str(exc_info.value).lower() + + def test_base_class_cannot_be_instantiated_directly(self): + """Test that base class cannot be instantiated directly.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + + # Python's ABC prevents instantiation of abstract base class + with pytest.raises(TypeError) as exc_info: + IntrinsicFunctionResolver(context, None) + + assert "abstract" in str(exc_info.value).lower() + + +class TestIntrinsicResolverPartialMode: + """Tests for IntrinsicResolver partial resolution mode. + + Requirements: + - 16.1: Support partial resolution mode that preserves Fn::Ref to resources + - 16.2: Support partial resolution mode that preserves Fn::GetAtt references + - 16.3: Support partial resolution mode that preserves Fn::ImportValue references + - 16.4: In partial mode, still resolve Fn::ForEach, Fn::Length, Fn::ToJsonString, + and Fn::FindInMap with DefaultValue + - 16.5: In partial mode, resolve Fn::If conditions where condition value is known + - 16.6: Allow configuration of which intrinsic functions to preserve vs resolve + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + @pytest.fixture + def full_context(self) -> TemplateProcessingContext: + """Create a context in full resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.FULL, + ) + + @pytest.fixture + def context_with_params(self) -> TemplateProcessingContext: + """Create a context with parameters defined.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parameter_values={"Environment": "prod", "BucketName": "my-bucket"}, + ) + context.parsed_template = ParsedTemplate( + parameters={ + "Environment": {"Type": "String"}, + "BucketName": {"Type": "String"}, + }, + resources={"MyBucket": {"Type": "AWS::S3::Bucket"}}, + ) + return context + + # Tests for Fn::GetAtt preservation (Requirement 16.2) + + def test_preserve_fn_getatt_in_partial_mode(self, partial_context: TemplateProcessingContext): + """Test that Fn::GetAtt is preserved in partial resolution mode. + + Requirement 16.2: Support partial resolution mode that preserves Fn::GetAtt references + """ + orchestrator = IntrinsicResolver(partial_context) + + value = {"Fn::GetAtt": ["MyBucket", "Arn"]} + result = orchestrator.resolve_value(value) + + assert result == {"Fn::GetAtt": ["MyBucket", "Arn"]} + + def test_fn_getatt_not_preserved_in_full_mode(self, full_context: TemplateProcessingContext): + """Test that Fn::GetAtt is not preserved in full resolution mode.""" + orchestrator = IntrinsicResolver(full_context) + + value = {"Fn::GetAtt": ["MyBucket", "Arn"]} + result = orchestrator.resolve_value(value) + + # In full mode without a resolver, it's returned as-is (not preserved) + # The dict is processed recursively but no resolver handles it + assert result == {"Fn::GetAtt": ["MyBucket", "Arn"]} + + def test_preserve_fn_getatt_with_nested_intrinsics(self, partial_context: TemplateProcessingContext): + """Test that Fn::GetAtt arguments are still resolved in partial mode.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + # Fn::GetAtt with nested Fn::Mock in arguments + value = {"Fn::GetAtt": [{"Fn::Mock": "Resource"}, "Arn"]} + result = orchestrator.resolve_value(value) + + # Fn::GetAtt is preserved but its arguments are resolved + assert result == {"Fn::GetAtt": ["Resource", "Arn"]} + + # Tests for Fn::ImportValue preservation (Requirement 16.3) + + def test_preserve_fn_importvalue_in_partial_mode(self, partial_context: TemplateProcessingContext): + """Test that Fn::ImportValue is preserved in partial resolution mode. + + Requirement 16.3: Support partial resolution mode that preserves Fn::ImportValue references + """ + orchestrator = IntrinsicResolver(partial_context) + + value = {"Fn::ImportValue": "SharedVpcId"} + result = orchestrator.resolve_value(value) + + assert result == {"Fn::ImportValue": "SharedVpcId"} + + def test_preserve_fn_importvalue_with_sub(self, partial_context: TemplateProcessingContext): + """Test that Fn::ImportValue with nested Fn::Sub is preserved.""" + orchestrator = IntrinsicResolver(partial_context) + + value = {"Fn::ImportValue": {"Fn::Sub": "${Environment}-VpcId"}} + result = orchestrator.resolve_value(value) + + # ImportValue is preserved, but Fn::Sub inside is not resolved (no resolver) + assert result == {"Fn::ImportValue": {"Fn::Sub": "${Environment}-VpcId"}} + + # Tests for Ref preservation (Requirement 16.1) + + def test_preserve_ref_to_resource_in_partial_mode(self, context_with_params: TemplateProcessingContext): + """Test that Ref to resources is preserved in partial resolution mode. + + Requirement 16.1: Support partial resolution mode that preserves Fn::Ref to resources + """ + orchestrator = IntrinsicResolver(context_with_params) + + # MyBucket is a resource, not a parameter + value = {"Ref": "MyBucket"} + result = orchestrator.resolve_value(value) + + assert result == {"Ref": "MyBucket"} + + def test_ref_to_parameter_not_preserved(self, context_with_params: TemplateProcessingContext): + """Test that Ref to parameters is NOT preserved (can be resolved).""" + orchestrator = IntrinsicResolver(context_with_params) + + # Environment is a parameter, not a resource + value = {"Ref": "Environment"} + result = orchestrator.resolve_value(value) + + # Without a Ref resolver, it's returned as-is but NOT marked as preserved + # The key difference is it goes through normal resolution path + assert result == {"Ref": "Environment"} + + def test_ref_to_pseudo_parameter_not_preserved(self, partial_context: TemplateProcessingContext): + """Test that Ref to pseudo-parameters is NOT preserved.""" + orchestrator = IntrinsicResolver(partial_context) + + # AWS::Region is a pseudo-parameter + value = {"Ref": "AWS::Region"} + result = orchestrator.resolve_value(value) + + # Without a Ref resolver, it's returned as-is but NOT marked as preserved + assert result == {"Ref": "AWS::Region"} + + def test_ref_to_aws_account_id_not_preserved(self, partial_context: TemplateProcessingContext): + """Test that Ref to AWS::AccountId is NOT preserved.""" + orchestrator = IntrinsicResolver(partial_context) + + value = {"Ref": "AWS::AccountId"} + result = orchestrator.resolve_value(value) + + assert result == {"Ref": "AWS::AccountId"} + + def test_ref_to_aws_stack_name_not_preserved(self, partial_context: TemplateProcessingContext): + """Test that Ref to AWS::StackName is NOT preserved.""" + orchestrator = IntrinsicResolver(partial_context) + + value = {"Ref": "AWS::StackName"} + result = orchestrator.resolve_value(value) + + assert result == {"Ref": "AWS::StackName"} + + # Tests for Fn::GetAZs preservation + + def test_preserve_fn_getazs_in_partial_mode(self, partial_context: TemplateProcessingContext): + """Test that Fn::GetAZs is preserved in partial resolution mode.""" + orchestrator = IntrinsicResolver(partial_context) + + value = {"Fn::GetAZs": "us-east-1"} + result = orchestrator.resolve_value(value) + + assert result == {"Fn::GetAZs": "us-east-1"} + + # Tests for resolvable intrinsics in partial mode (Requirement 16.4) + + def test_resolve_fn_mock_in_partial_mode(self, partial_context: TemplateProcessingContext): + """Test that registered resolvers still work in partial mode. + + Requirement 16.4: In partial mode, still resolve Fn::ForEach, Fn::Length, etc. + """ + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + value = {"Fn::Mock": 5} + result = orchestrator.resolve_value(value) + + # MockResolver should still resolve this + assert result == 10 + + def test_resolve_nested_with_preserved_intrinsic(self, partial_context: TemplateProcessingContext): + """Test resolving nested structure with both resolvable and preserved intrinsics.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + value = { + "resolved": {"Fn::Mock": 5}, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + result = orchestrator.resolve_value(value) + + assert result == { + "resolved": 10, + "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + + # Tests for preserve_functions configuration (Requirement 16.6) + + def test_default_preserve_functions(self, partial_context: TemplateProcessingContext): + """Test that default preserve functions are set correctly. + + Requirement 16.6: Allow configuration of which intrinsic functions to preserve vs resolve + """ + orchestrator = IntrinsicResolver(partial_context) + + expected = {"Fn::GetAtt", "Fn::ImportValue", "Fn::GetAZs", "Fn::Cidr"} + assert orchestrator.preserve_functions == expected + + def test_custom_preserve_functions(self, partial_context: TemplateProcessingContext): + """Test creating orchestrator with custom preserve functions.""" + custom_preserve = {"Fn::GetAtt", "Fn::CustomFunction"} + orchestrator = IntrinsicResolver(partial_context, preserve_functions=custom_preserve) + + assert orchestrator.preserve_functions == custom_preserve + + def test_set_preserve_functions(self, partial_context: TemplateProcessingContext): + """Test setting preserve functions after creation.""" + orchestrator = IntrinsicResolver(partial_context) + + new_preserve = {"Fn::GetAtt", "Fn::ImportValue"} + result = orchestrator.set_preserve_functions(new_preserve) + + assert result is orchestrator # Returns self for chaining + assert orchestrator.preserve_functions == new_preserve + + def test_add_preserve_function(self, partial_context: TemplateProcessingContext): + """Test adding a function to preserve list.""" + orchestrator = IntrinsicResolver(partial_context) + + result = orchestrator.add_preserve_function("Fn::CustomFunction") + + assert result is orchestrator + assert "Fn::CustomFunction" in orchestrator.preserve_functions + + def test_remove_preserve_function(self, partial_context: TemplateProcessingContext): + """Test removing a function from preserve list.""" + orchestrator = IntrinsicResolver(partial_context) + + result = orchestrator.remove_preserve_function("Fn::GetAtt") + + assert result is orchestrator + assert "Fn::GetAtt" not in orchestrator.preserve_functions + + def test_custom_function_preserved(self, partial_context: TemplateProcessingContext): + """Test that custom functions can be preserved.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.add_preserve_function("Fn::CustomFunction") + + value = {"Fn::CustomFunction": "args"} + result = orchestrator.resolve_value(value) + + assert result == {"Fn::CustomFunction": "args"} + + def test_removed_function_not_preserved(self, partial_context: TemplateProcessingContext): + """Test that removed functions are no longer preserved.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.remove_preserve_function("Fn::GetAtt") + + value = {"Fn::GetAtt": ["MyBucket", "Arn"]} + result = orchestrator.resolve_value(value) + + # Without preservation and without a resolver, it's processed normally + assert result == {"Fn::GetAtt": ["MyBucket", "Arn"]} + + def test_preserve_functions_returns_copy(self, partial_context: TemplateProcessingContext): + """Test that preserve_functions returns a copy.""" + orchestrator = IntrinsicResolver(partial_context) + + preserve = orchestrator.preserve_functions + preserve.add("Fn::Modified") + + # Original should be unchanged + assert "Fn::Modified" not in orchestrator.preserve_functions + + +class TestIntrinsicResolverIsIntrinsicFunction: + """Tests for IntrinsicResolver._is_intrinsic_function() method.""" + + @pytest.fixture + def orchestrator(self) -> IntrinsicResolver: + """Create an orchestrator for testing.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + return IntrinsicResolver(context) + + def test_fn_prefix_is_intrinsic(self, orchestrator: IntrinsicResolver): + """Test that Fn:: prefixed functions are recognized.""" + assert orchestrator._is_intrinsic_function({"Fn::GetAtt": ["A", "B"]}) is True + assert orchestrator._is_intrinsic_function({"Fn::Sub": "hello"}) is True + assert orchestrator._is_intrinsic_function({"Fn::Length": [1, 2]}) is True + + def test_ref_is_intrinsic(self, orchestrator: IntrinsicResolver): + """Test that Ref is recognized as intrinsic.""" + assert orchestrator._is_intrinsic_function({"Ref": "MyResource"}) is True + + def test_condition_is_intrinsic(self, orchestrator: IntrinsicResolver): + """Test that Condition is recognized as intrinsic.""" + assert orchestrator._is_intrinsic_function({"Condition": "MyCondition"}) is True + + def test_non_intrinsic_dict(self, orchestrator: IntrinsicResolver): + """Test that regular dicts are not recognized as intrinsic.""" + assert orchestrator._is_intrinsic_function({"Type": "AWS::S3::Bucket"}) is False + assert orchestrator._is_intrinsic_function({"key": "value"}) is False + + def test_multi_key_dict_not_intrinsic(self, orchestrator: IntrinsicResolver): + """Test that multi-key dicts are not recognized as intrinsic.""" + assert orchestrator._is_intrinsic_function({"Fn::Sub": "a", "extra": "b"}) is False + + def test_non_dict_not_intrinsic(self, orchestrator: IntrinsicResolver): + """Test that non-dict values are not recognized as intrinsic.""" + assert orchestrator._is_intrinsic_function("string") is False + assert orchestrator._is_intrinsic_function(123) is False + assert orchestrator._is_intrinsic_function([1, 2, 3]) is False + assert orchestrator._is_intrinsic_function(None) is False + + +class TestIntrinsicResolverIsResourceRef: + """Tests for IntrinsicResolver._is_resource_ref() method.""" + + @pytest.fixture + def context_with_params(self) -> TemplateProcessingContext: + """Create a context with parameters and resources.""" + context = TemplateProcessingContext( + fragment={"Resources": {}}, + parameter_values={"ParamFromValues": "value"}, + ) + context.parsed_template = ParsedTemplate( + parameters={"ParamFromTemplate": {"Type": "String"}}, + resources={"MyResource": {"Type": "AWS::S3::Bucket"}}, + ) + return context + + def test_pseudo_parameter_not_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that pseudo-parameters are not resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + pseudo_params = [ + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", + ] + + for param in pseudo_params: + assert orchestrator._is_resource_ref({"Ref": param}) is False + + def test_template_parameter_not_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that template parameters are not resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + assert orchestrator._is_resource_ref({"Ref": "ParamFromTemplate"}) is False + + def test_parameter_values_not_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that parameters from parameter_values are not resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + assert orchestrator._is_resource_ref({"Ref": "ParamFromValues"}) is False + + def test_resource_is_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that resources are identified as resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + # MyResource is in resources, not parameters + assert orchestrator._is_resource_ref({"Ref": "MyResource"}) is True + + def test_unknown_ref_is_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that unknown refs are assumed to be resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + # UnknownThing is not a parameter or pseudo-param, assume resource + assert orchestrator._is_resource_ref({"Ref": "UnknownThing"}) is True + + def test_non_string_ref_not_resource_ref(self, context_with_params: TemplateProcessingContext): + """Test that non-string Ref values are not resource refs.""" + orchestrator = IntrinsicResolver(context_with_params) + + assert orchestrator._is_resource_ref({"Ref": 123}) is False + assert orchestrator._is_resource_ref({"Ref": ["list"]}) is False + + +class TestIntrinsicResolverRecursiveResolution: + """Tests for recursive resolution in IntrinsicResolver.""" + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + """Create a context in partial resolution mode.""" + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + ) + + def test_deeply_nested_structure(self, partial_context: TemplateProcessingContext): + """Test resolution of deeply nested structures.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + value = {"level1": {"level2": {"level3": {"Fn::Mock": 5}}}} + result = orchestrator.resolve_value(value) + + assert result == {"level1": {"level2": {"level3": 10}}} + + def test_mixed_list_and_dict(self, partial_context: TemplateProcessingContext): + """Test resolution of mixed list and dict structures.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + value = { + "items": [ + {"Fn::Mock": 1}, + {"nested": {"Fn::Mock": 2}}, + [{"Fn::Mock": 3}], + ] + } + result = orchestrator.resolve_value(value) + + assert result == { + "items": [ + 2, + {"nested": 4}, + [6], + ] + } + + def test_preserved_intrinsic_with_resolvable_args(self, partial_context: TemplateProcessingContext): + """Test that preserved intrinsics have their arguments resolved.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(MockResolver) + + # Fn::GetAtt is preserved, but its arguments should be resolved + value = {"Fn::GetAtt": [{"Fn::Mock": "ResourceName"}, "Arn"]} # This should be resolved + result = orchestrator.resolve_value(value) + + # Fn::GetAtt preserved, but Fn::Mock in args resolved + assert result == {"Fn::GetAtt": ["ResourceName", "Arn"]} diff --git a/tests/unit/lib/cfn_language_extensions/test_serialization.py b/tests/unit/lib/cfn_language_extensions/test_serialization.py new file mode 100644 index 00000000000..45d3c8f3ef6 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_serialization.py @@ -0,0 +1,770 @@ +""" +Unit tests for the serialization module. + +Tests cover: +- JSON serialization functionality +- YAML serialization functionality +- Round-trip serialization (serialize then parse back) +- CloudFormation-specific YAML features (multi-line strings) +- Error handling for invalid inputs + +Requirements: + - 13.1: THE Package SHALL provide a function to serialize processed templates to JSON format + - 13.2: THE Package SHALL provide a function to serialize processed templates to YAML format + - 13.3: JSON serialization SHALL produce valid JSON that can be parsed back + - 13.4: YAML serialization SHALL produce valid YAML that can be parsed back +""" + +import json +import pytest +from typing import Any, Dict + +import yaml + +from samcli.lib.cfn_language_extensions.serialization import ( + serialize_to_json, + serialize_to_yaml, +) + + +# ============================================================================= +# Unit Tests for serialize_to_json +# ============================================================================= + + +class TestSerializeToJsonBasicFunctionality: + """Tests for basic JSON serialization functionality. + + Requirement 13.1: THE Package SHALL provide a function to serialize + processed templates to JSON format + """ + + def test_serialize_empty_template(self): + """Test serializing an empty template.""" + template: Dict[str, Any] = {} + result = serialize_to_json(template) + assert result == "{}" + + def test_serialize_minimal_template(self): + """Test serializing a minimal CloudFormation template.""" + template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + result = serialize_to_json(template) + + # Should be valid JSON + parsed = json.loads(result) + assert parsed == template + + def test_serialize_full_template(self): + """Test serializing a full CloudFormation template with all sections.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Test template", + "Parameters": {"Environment": {"Type": "String", "Default": "dev"}}, + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}}, + "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Environment"}, "prod"]}}, + "Resources": {"MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": {"Ref": "Environment"}}}}, + "Outputs": {"QueueUrl": {"Value": {"Fn::GetAtt": ["MyQueue", "QueueUrl"]}}}, + } + result = serialize_to_json(template) + + # Should be valid JSON + parsed = json.loads(result) + assert parsed == template + + def test_serialize_with_intrinsic_functions(self): + """Test serializing template with CloudFormation intrinsic functions.""" + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${AWS::StackName}-function"}, + "Environment": { + "Variables": { + "TABLE_NAME": {"Ref": "MyTable"}, + "BUCKET_ARN": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + }, + }, + } + } + } + result = serialize_to_json(template) + + # Should be valid JSON + parsed = json.loads(result) + assert parsed == template + + def test_serialize_with_nested_structures(self): + """Test serializing template with deeply nested structures.""" + template = { + "Resources": { + "MyResource": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": {"Level1": {"Level2": {"Level3": {"Level4": {"Value": "deep"}}}}}, + } + } + } + result = serialize_to_json(template) + + # Should be valid JSON + parsed = json.loads(result) + assert parsed == template + + +class TestSerializeToJsonOptions: + """Tests for JSON serialization options.""" + + def test_serialize_with_indent(self): + """Test serializing with custom indentation.""" + template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + + # Default indent (2) + result_default = serialize_to_json(template) + assert " " in result_default # 2-space indent + + # Custom indent (4) + result_4 = serialize_to_json(template, indent=4) + assert " " in result_4 # 4-space indent + + # No indent (compact) + result_compact = serialize_to_json(template, indent=None) + assert "\n" not in result_compact # No newlines in compact mode + + def test_serialize_with_sort_keys(self): + """Test serializing with sorted keys.""" + template = {"z_key": 1, "a_key": 2, "m_key": 3} + + # Without sorting + result_unsorted = serialize_to_json(template, sort_keys=False) + + # With sorting + result_sorted = serialize_to_json(template, sort_keys=True) + + # Both should be valid JSON + assert json.loads(result_unsorted) == template + assert json.loads(result_sorted) == template + + # Sorted version should have keys in alphabetical order + assert result_sorted.index("a_key") < result_sorted.index("m_key") + assert result_sorted.index("m_key") < result_sorted.index("z_key") + + def test_serialize_with_unicode(self): + """Test serializing template with Unicode characters.""" + template = { + "Resources": { + "MyResource": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": {"Message": "Hello, 世界! 🌍"}, + } + } + } + result = serialize_to_json(template) + + # Should preserve Unicode characters + assert "世界" in result + assert "🌍" in result + + # Should be valid JSON + parsed = json.loads(result) + assert parsed == template + + +class TestSerializeToJsonRoundTrip: + """Tests for JSON serialization round-trip. + + Requirement 13.3: JSON serialization SHALL produce valid JSON that can be parsed back + """ + + def test_round_trip_empty_template(self): + """Test round-trip for empty template.""" + template: Dict[str, Any] = {} + result = serialize_to_json(template) + parsed = json.loads(result) + assert parsed == template + + def test_round_trip_with_all_json_types(self): + """Test round-trip with all JSON data types.""" + template = { + "string": "hello", + "integer": 42, + "float": 3.14, + "boolean_true": True, + "boolean_false": False, + "null": None, + "array": [1, 2, 3], + "object": {"nested": "value"}, + } + result = serialize_to_json(template) + parsed = json.loads(result) + assert parsed == template + + def test_round_trip_complex_template(self): + """Test round-trip for a complex CloudFormation template.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Description": "Complex test template", + "Parameters": { + "Environment": {"Type": "String", "Default": "dev"}, + "InstanceCount": {"Type": "Number", "Default": 1}, + }, + "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Environment"}, "prod"]}}, + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": {"Fn::Sub": "${AWS::StackName}-queue"}, + "Tags": [{"Key": "Environment", "Value": {"Ref": "Environment"}}], + }, + } + }, + "Outputs": { + "QueueUrl": { + "Value": {"Fn::GetAtt": ["MyQueue", "QueueUrl"]}, + "Export": {"Name": {"Fn::Sub": "${AWS::StackName}-queue-url"}}, + } + }, + } + result = serialize_to_json(template) + parsed = json.loads(result) + assert parsed == template + + +# ============================================================================= +# Unit Tests for serialize_to_yaml +# ============================================================================= + + +class TestSerializeToYamlBasicFunctionality: + """Tests for basic YAML serialization functionality. + + Requirement 13.2: THE Package SHALL provide a function to serialize + processed templates to YAML format + """ + + def test_serialize_empty_template(self): + """Test serializing an empty template.""" + template: Dict[str, Any] = {} + result = serialize_to_yaml(template) + assert result.strip() == "{}" + + def test_serialize_minimal_template(self): + """Test serializing a minimal CloudFormation template.""" + template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + result = serialize_to_yaml(template) + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + def test_serialize_full_template(self): + """Test serializing a full CloudFormation template with all sections.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Test template", + "Parameters": {"Environment": {"Type": "String", "Default": "dev"}}, + "Mappings": {"RegionMap": {"us-east-1": {"AMI": "ami-12345678"}}}, + "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Environment"}, "prod"]}}, + "Resources": {"MyQueue": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": {"Ref": "Environment"}}}}, + "Outputs": {"QueueUrl": {"Value": {"Fn::GetAtt": ["MyQueue", "QueueUrl"]}}}, + } + result = serialize_to_yaml(template) + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + def test_serialize_with_intrinsic_functions(self): + """Test serializing template with CloudFormation intrinsic functions.""" + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${AWS::StackName}-function"}, + "Environment": { + "Variables": { + "TABLE_NAME": {"Ref": "MyTable"}, + "BUCKET_ARN": {"Fn::GetAtt": ["MyBucket", "Arn"]}, + } + }, + }, + } + } + } + result = serialize_to_yaml(template) + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + +class TestSerializeToYamlMultiLineStrings: + """Tests for YAML serialization of multi-line strings. + + CloudFormation templates often contain multi-line strings for inline code, + policies, etc. These should be formatted using literal block scalar style. + """ + + def test_serialize_multiline_string(self): + """Test that multi-line strings use literal block style.""" + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import json\n\ndef handler(event, context):\n return {'statusCode': 200}" + } + }, + } + } + } + result = serialize_to_yaml(template) + + # Should use literal block style (|) for multi-line strings + assert "|" in result + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + def test_serialize_policy_document(self): + """Test serializing a template with an IAM policy document.""" + template = { + "Resources": { + "MyRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + }, + } + } + } + result = serialize_to_yaml(template) + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + +class TestSerializeToYamlOptions: + """Tests for YAML serialization options.""" + + def test_serialize_with_flow_style(self): + """Test serializing with flow style (inline collections).""" + template = {"Resources": {"MyQueue": {"Type": "AWS::SQS::Queue"}}} + + # Block style (default) + result_block = serialize_to_yaml(template, default_flow_style=False) + + # Flow style + result_flow = serialize_to_yaml(template, default_flow_style=True) + + # Both should be valid YAML + assert yaml.safe_load(result_block) == template + assert yaml.safe_load(result_flow) == template + + # Flow style should use inline format (curly braces) + assert "{" in result_flow + # Block style should use indentation (no curly braces for dicts) + assert "Resources:" in result_block + + def test_serialize_with_sort_keys(self): + """Test serializing with sorted keys.""" + template = {"z_key": 1, "a_key": 2, "m_key": 3} + + # Without sorting + result_unsorted = serialize_to_yaml(template, sort_keys=False) + + # With sorting + result_sorted = serialize_to_yaml(template, sort_keys=True) + + # Both should be valid YAML + assert yaml.safe_load(result_unsorted) == template + assert yaml.safe_load(result_sorted) == template + + # Sorted version should have keys in alphabetical order + assert result_sorted.index("a_key") < result_sorted.index("m_key") + assert result_sorted.index("m_key") < result_sorted.index("z_key") + + def test_serialize_with_unicode(self): + """Test serializing template with Unicode characters.""" + template = { + "Resources": { + "MyResource": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": {"Message": "Hello, 世界! 🌍"}, + } + } + } + result = serialize_to_yaml(template) + + # Should preserve Unicode characters + assert "世界" in result + assert "🌍" in result + + # Should be valid YAML + parsed = yaml.safe_load(result) + assert parsed == template + + +class TestSerializeToYamlRoundTrip: + """Tests for YAML serialization round-trip. + + Requirement 13.4: YAML serialization SHALL produce valid YAML that can be parsed back + """ + + def test_round_trip_empty_template(self): + """Test round-trip for empty template.""" + template: Dict[str, Any] = {} + result = serialize_to_yaml(template) + parsed = yaml.safe_load(result) + assert parsed == template + + def test_round_trip_with_all_yaml_types(self): + """Test round-trip with all YAML data types.""" + template = { + "string": "hello", + "integer": 42, + "float": 3.14, + "boolean_true": True, + "boolean_false": False, + "null": None, + "array": [1, 2, 3], + "object": {"nested": "value"}, + } + result = serialize_to_yaml(template) + parsed = yaml.safe_load(result) + assert parsed == template + + def test_round_trip_complex_template(self): + """Test round-trip for a complex CloudFormation template.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Description": "Complex test template", + "Parameters": { + "Environment": {"Type": "String", "Default": "dev"}, + "InstanceCount": {"Type": "Number", "Default": 1}, + }, + "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Environment"}, "prod"]}}, + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": {"Fn::Sub": "${AWS::StackName}-queue"}, + "Tags": [{"Key": "Environment", "Value": {"Ref": "Environment"}}], + }, + } + }, + "Outputs": { + "QueueUrl": { + "Value": {"Fn::GetAtt": ["MyQueue", "QueueUrl"]}, + "Export": {"Name": {"Fn::Sub": "${AWS::StackName}-queue-url"}}, + } + }, + } + result = serialize_to_yaml(template) + parsed = yaml.safe_load(result) + assert parsed == template + + def test_round_trip_with_multiline_strings(self): + """Test round-trip for template with multi-line strings.""" + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import json\n\ndef handler(event, context):\n print('Hello')\n return {'statusCode': 200}" + } + }, + } + } + } + result = serialize_to_yaml(template) + parsed = yaml.safe_load(result) + assert parsed == template + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestSerializationIntegration: + """Integration tests for serialization functions.""" + + def test_json_and_yaml_produce_equivalent_data(self): + """Test that JSON and YAML serialization produce equivalent data when parsed.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "test-queue", + "DelaySeconds": 5, + "Tags": [{"Key": "Environment", "Value": "test"}], + }, + } + }, + } + + json_result = serialize_to_json(template) + yaml_result = serialize_to_yaml(template) + + json_parsed = json.loads(json_result) + yaml_parsed = yaml.safe_load(yaml_result) + + assert json_parsed == yaml_parsed == template + + def test_serialize_processed_template_with_expanded_foreach(self): + """Test serializing a template that has been processed (ForEach expanded).""" + # This represents a template after Fn::ForEach has been expanded + template = { + "Resources": { + "QueueA": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "queue-a"}}, + "QueueB": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "queue-b"}}, + "QueueC": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "queue-c"}}, + } + } + + # Both formats should work + json_result = serialize_to_json(template) + yaml_result = serialize_to_yaml(template) + + assert json.loads(json_result) == template + assert yaml.safe_load(yaml_result) == template + + def test_serialize_template_with_preserved_intrinsics(self): + """Test serializing a template with preserved intrinsic functions.""" + # This represents a template processed in partial mode + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": "my-function", + "Role": {"Fn::GetAtt": ["MyRole", "Arn"]}, + "Environment": {"Variables": {"BUCKET_NAME": {"Ref": "MyBucket"}}}, + }, + } + } + } + + # Both formats should preserve the intrinsic functions + json_result = serialize_to_json(template) + yaml_result = serialize_to_yaml(template) + + json_parsed = json.loads(json_result) + yaml_parsed = yaml.safe_load(yaml_result) + + # Intrinsic functions should be preserved + assert json_parsed["Resources"]["MyFunction"]["Properties"]["Role"] == {"Fn::GetAtt": ["MyRole", "Arn"]} + assert yaml_parsed["Resources"]["MyFunction"]["Properties"]["Role"] == {"Fn::GetAtt": ["MyRole", "Arn"]} + + +# ============================================================================= +# Parametrized Tests for Serialization Round-Trip +# ============================================================================= + + +# Concrete template examples for parametrized tests +_SIMPLE_TEMPLATE = { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyQueue": { + "Type": "AWS::SQS::Queue", + "Properties": {"QueueName": "test-queue", "DelaySeconds": 5}, + } + }, +} + +_TEMPLATE_WITH_PARAMS = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Template with parameters", + "Parameters": { + "Environment": {"Type": "String", "Default": "dev"}, + "InstanceCount": {"Type": "Number", "Default": 1}, + }, + "Resources": { + "MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": "test-topic"}}, + }, + "Outputs": { + "TopicArn": {"Value": "arn:aws:sns:us-east-1:123456789012:test-topic", "Description": "Topic ARN"}, + }, +} + +_TEMPLATE_WITH_INTRINSICS = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${AWS::StackName}-function"}, + "Role": {"Fn::GetAtt": ["MyRole", "Arn"]}, + "Environment": { + "Variables": { + "TABLE_NAME": {"Ref": "MyTable"}, + "REGION": {"Fn::Join": ["-", ["us", "east", "1"]]}, + } + }, + }, + } + }, + "Outputs": { + "FunctionArn": {"Value": {"Fn::GetAtt": ["MyFunction", "Arn"]}}, + }, +} + + +class TestSerializationRoundTripParametrized: + """ + Parametrized tests for Serialization Round-Trip. + + For any processed template dictionary, serializing to JSON and parsing back + SHALL produce an equivalent dictionary; similarly for YAML serialization. + + **Validates: Requirements 13.3, 13.4** + """ + + @pytest.mark.parametrize( + "template", + [_SIMPLE_TEMPLATE, _TEMPLATE_WITH_PARAMS, _TEMPLATE_WITH_INTRINSICS], + ids=["simple", "with_params", "with_intrinsics"], + ) + def test_json_serialization_round_trip(self, template: Dict[str, Any]): + """ + For any valid CloudFormation template structure, serializing to JSON + and parsing back produces the original template. + + **Validates: Requirements 13.3, 13.4** + """ + json_str = serialize_to_json(template) + parsed = json.loads(json_str) + assert parsed == template + + @pytest.mark.parametrize( + "template", + [_SIMPLE_TEMPLATE, _TEMPLATE_WITH_PARAMS, _TEMPLATE_WITH_INTRINSICS], + ids=["simple", "with_params", "with_intrinsics"], + ) + def test_yaml_serialization_round_trip(self, template: Dict[str, Any]): + """ + For any valid CloudFormation template structure, serializing to YAML + and parsing back produces the original template. + + **Validates: Requirements 13.3, 13.4** + """ + yaml_str = serialize_to_yaml(template) + parsed = yaml.safe_load(yaml_str) + assert parsed == template + + @pytest.mark.parametrize( + "template", + [_SIMPLE_TEMPLATE, _TEMPLATE_WITH_PARAMS, _TEMPLATE_WITH_INTRINSICS], + ids=["simple", "with_params", "with_intrinsics"], + ) + def test_json_and_yaml_produce_equivalent_data(self, template: Dict[str, Any]): + """ + JSON and YAML serialization of the same template produce equivalent + data when parsed. + + **Validates: Requirements 13.3, 13.4** + """ + json_str = serialize_to_json(template) + yaml_str = serialize_to_yaml(template) + + json_parsed = json.loads(json_str) + yaml_parsed = yaml.safe_load(yaml_str) + + assert json_parsed == yaml_parsed + + @pytest.mark.parametrize( + "template", + [_TEMPLATE_WITH_INTRINSICS], + ids=["with_intrinsics"], + ) + def test_json_round_trip_preserves_intrinsic_functions(self, template: Dict[str, Any]): + """ + For any CloudFormation template with intrinsic functions, serializing + to JSON and parsing back preserves the intrinsic function structure. + + **Validates: Requirements 13.3, 13.4** + """ + json_str = serialize_to_json(template) + parsed = json.loads(json_str) + assert parsed == template + + @pytest.mark.parametrize( + "template", + [_TEMPLATE_WITH_INTRINSICS], + ids=["with_intrinsics"], + ) + def test_yaml_round_trip_preserves_intrinsic_functions(self, template: Dict[str, Any]): + """ + For any CloudFormation template with intrinsic functions, serializing + to YAML and parsing back preserves the intrinsic function structure. + + **Validates: Requirements 13.3, 13.4** + """ + yaml_str = serialize_to_yaml(template) + parsed = yaml.safe_load(yaml_str) + assert parsed == template + + @pytest.mark.parametrize( + "template, indent, sort_keys", + [ + (_SIMPLE_TEMPLATE, None, False), + (_SIMPLE_TEMPLATE, 4, True), + (_TEMPLATE_WITH_PARAMS, 2, False), + ], + ids=["compact_unsorted", "indent4_sorted", "indent2_unsorted"], + ) + def test_json_round_trip_with_options(self, template: Dict[str, Any], indent: int, sort_keys: bool): + """ + For any valid CloudFormation template and any serialization options, + serializing to JSON and parsing back produces the original template. + + **Validates: Requirements 13.3, 13.4** + """ + json_str = serialize_to_json(template, indent=indent, sort_keys=sort_keys) + parsed = json.loads(json_str) + assert parsed == template + + @pytest.mark.parametrize( + "template, default_flow_style, sort_keys", + [ + (_SIMPLE_TEMPLATE, False, False), + (_SIMPLE_TEMPLATE, True, True), + (_TEMPLATE_WITH_PARAMS, False, True), + ], + ids=["block_unsorted", "flow_sorted", "block_sorted"], + ) + def test_yaml_round_trip_with_options(self, template: Dict[str, Any], default_flow_style: bool, sort_keys: bool): + """ + For any valid CloudFormation template and any serialization options, + serializing to YAML and parsing back produces the original template. + + **Validates: Requirements 13.3, 13.4** + """ + yaml_str = serialize_to_yaml( + template, + default_flow_style=default_flow_style, + sort_keys=sort_keys, + ) + parsed = yaml.safe_load(yaml_str) + assert parsed == template diff --git a/tests/unit/lib/cfn_language_extensions/test_update_replace_policy.py b/tests/unit/lib/cfn_language_extensions/test_update_replace_policy.py new file mode 100644 index 00000000000..d8ecff30b22 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_update_replace_policy.py @@ -0,0 +1,780 @@ +""" +Unit tests for the UpdateReplacePolicyProcessor class. + +Tests cover: +- String policy values (valid) +- Parameter reference resolution +- AWS::NoValue rejection +- Invalid policy value handling +- Error messages + +Requirements: + - 7.2: WHEN a resource has an UpdateReplacePolicy attribute, THEN THE Processor + SHALL resolve any parameter references in the policy value + - 7.3: WHEN UpdateReplacePolicy contains a Ref to a parameter, THEN THE Processor + SHALL substitute the parameter's value + - 7.4: WHEN UpdateReplacePolicy resolves to AWS::NoValue, THEN THE Processor + SHALL raise an Invalid_Template_Exception + - 7.5: WHEN UpdateReplacePolicy does not resolve to a valid string value, THEN + THE Processor SHALL raise an Invalid_Template_Exception +""" + +import pytest +from typing import Any, Dict, List + +from samcli.lib.cfn_language_extensions.models import ( + TemplateProcessingContext, + ParsedTemplate, +) +from samcli.lib.cfn_language_extensions.processors.update_replace_policy import UpdateReplacePolicyProcessor +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException + + +class TestUpdateReplacePolicyProcessorStringValues: + """Tests for UpdateReplacePolicyProcessor with string policy values.""" + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_string_delete_policy_unchanged(self, processor: UpdateReplacePolicyProcessor): + """Test that string 'Delete' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": "Delete"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Delete" + + def test_string_retain_policy_unchanged(self, processor: UpdateReplacePolicyProcessor): + """Test that string 'Retain' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": "Retain"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Retain" + + def test_string_snapshot_policy_unchanged(self, processor: UpdateReplacePolicyProcessor): + """Test that string 'Snapshot' policy is left unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyVolume": {"Type": "AWS::EC2::Volume", "UpdateReplacePolicy": "Snapshot"}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyVolume"]["UpdateReplacePolicy"] == "Snapshot" + + def test_resource_without_update_replace_policy_unchanged(self, processor: UpdateReplacePolicyProcessor): + """Test that resources without UpdateReplacePolicy are unchanged.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "my-bucket"}}}} + ) + + processor.process_template(context) + + assert "UpdateReplacePolicy" not in context.fragment["Resources"]["MyBucket"] + + def test_multiple_resources_with_policies(self, processor: UpdateReplacePolicyProcessor): + """Test processing multiple resources with different policies.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": "Retain"}, + "Bucket2": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": "Delete"}, + "Volume1": {"Type": "AWS::EC2::Volume", "UpdateReplacePolicy": "Snapshot"}, + } + } + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["UpdateReplacePolicy"] == "Retain" + assert context.fragment["Resources"]["Bucket2"]["UpdateReplacePolicy"] == "Delete" + assert context.fragment["Resources"]["Volume1"]["UpdateReplacePolicy"] == "Snapshot" + + +class TestUpdateReplacePolicyProcessorParameterResolution: + """Tests for UpdateReplacePolicyProcessor parameter reference resolution. + + Requirement 7.2: WHEN a resource has an UpdateReplacePolicy attribute, THEN THE + Processor SHALL resolve any parameter references in the policy value + + Requirement 7.3: WHEN UpdateReplacePolicy contains a Ref to a parameter, THEN + THE Processor SHALL substitute the parameter's value + """ + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_resolve_ref_to_parameter_retain(self, processor: UpdateReplacePolicyProcessor): + """Test resolving Ref to parameter with 'Retain' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": "Retain"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Retain" + + def test_resolve_ref_to_parameter_delete(self, processor: UpdateReplacePolicyProcessor): + """Test resolving Ref to parameter with 'Delete' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": "Delete"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Delete" + + def test_resolve_ref_to_parameter_snapshot(self, processor: UpdateReplacePolicyProcessor): + """Test resolving Ref to parameter with 'Snapshot' value. + + Requirement 7.3: Substitute parameter value for Ref + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyVolume": {"Type": "AWS::EC2::Volume", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": "Snapshot"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyVolume"]["UpdateReplacePolicy"] == "Snapshot" + + def test_resolve_ref_to_parameter_default_value(self, processor: UpdateReplacePolicyProcessor): + """Test resolving Ref to parameter using default value. + + Requirement 7.2: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={}, + parsed_template=ParsedTemplate( + parameters={"PolicyParam": {"Type": "String", "Default": "Retain"}}, resources={} + ), + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Retain" + + def test_resolve_multiple_refs_to_same_parameter(self, processor: UpdateReplacePolicyProcessor): + """Test resolving multiple Refs to the same parameter. + + Requirement 7.2: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}, + "Bucket2": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}, + } + }, + parameter_values={"PolicyParam": "Retain"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["UpdateReplacePolicy"] == "Retain" + assert context.fragment["Resources"]["Bucket2"]["UpdateReplacePolicy"] == "Retain" + + def test_resolve_refs_to_different_parameters(self, processor: UpdateReplacePolicyProcessor): + """Test resolving Refs to different parameters. + + Requirement 7.2: Resolve parameter references in policy + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "Bucket1": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "BucketPolicy"}}, + "Volume1": {"Type": "AWS::EC2::Volume", "UpdateReplacePolicy": {"Ref": "VolumePolicy"}}, + } + }, + parameter_values={"BucketPolicy": "Retain", "VolumePolicy": "Snapshot"}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["Bucket1"]["UpdateReplacePolicy"] == "Retain" + assert context.fragment["Resources"]["Volume1"]["UpdateReplacePolicy"] == "Snapshot" + + +class TestUpdateReplacePolicyProcessorAwsNoValue: + """Tests for UpdateReplacePolicyProcessor AWS::NoValue rejection. + + Requirement 7.4: WHEN UpdateReplacePolicy resolves to AWS::NoValue, THEN THE + Processor SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_ref_to_aws_novalue_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that Ref to AWS::NoValue raises InvalidTemplateException. + + Requirement 7.4: Raise exception for AWS::NoValue references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "AWS::NoValue"}}} + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "AWS::NoValue is not supported for DeletionPolicy or UpdateReplacePolicy" in str(exc_info.value) + + def test_aws_novalue_error_message_format(self, processor: UpdateReplacePolicyProcessor): + """Test that AWS::NoValue error message matches expected format. + + Requirement 7.4: Raise exception for AWS::NoValue references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "AWS::NoValue"}}} + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + # Verify exact error message format + error_message = str(exc_info.value) + assert "AWS::NoValue" in error_message + assert "DeletionPolicy" in error_message or "UpdateReplacePolicy" in error_message + + +class TestUpdateReplacePolicyProcessorInvalidValues: + """Tests for UpdateReplacePolicyProcessor invalid value handling. + + Requirement 7.5: WHEN UpdateReplacePolicy does not resolve to a valid string + value, THEN THE Processor SHALL raise an Invalid_Template_Exception + """ + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_list_policy_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that list policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": ["Retain", "Delete"]}} + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Every UpdateReplacePolicy member must be a string" in str(exc_info.value) + + def test_integer_policy_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that integer policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": 123}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_boolean_policy_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that boolean policy value raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string values + """ + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": True}}} + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_ref_to_nonexistent_parameter_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that Ref to non-existent parameter raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unresolvable references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "NonExistentParam"}} + } + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + assert "MyBucket" in str(exc_info.value) + + def test_ref_to_resource_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that Ref to resource raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unresolvable references + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "OtherResource"}}, + "OtherResource": {"Type": "AWS::SNS::Topic"}, + } + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_parameter_resolves_to_non_string_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that parameter resolving to non-string raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string resolved values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": 123}, # Integer, not string + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_parameter_resolves_to_list_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that parameter resolving to list raises InvalidTemplateException. + + Requirement 7.5: Raise exception for non-string resolved values + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}}} + }, + parameter_values={"PolicyParam": ["Retain"]}, # List, not string + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_unsupported_intrinsic_function_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that unsupported intrinsic function raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Fn::Sub": "Retain"}}} + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_fn_if_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that Fn::If raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": {"Fn::If": ["IsProd", "Retain", "Delete"]}, + } + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + def test_fn_getatt_raises_exception(self, processor: UpdateReplacePolicyProcessor): + """Test that Fn::GetAtt raises InvalidTemplateException. + + Requirement 7.5: Raise exception for unsupported expressions + """ + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": {"Fn::GetAtt": ["SomeResource", "SomeAttribute"]}, + } + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "Unsupported expression for UpdateReplacePolicy" in str(exc_info.value) + + +class TestUpdateReplacePolicyProcessorEdgeCases: + """Tests for UpdateReplacePolicyProcessor edge cases.""" + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_empty_resources_section(self, processor: UpdateReplacePolicyProcessor): + """Test processing template with empty Resources section.""" + context = TemplateProcessingContext(fragment={"Resources": {}}) + + # Should not raise any exception + processor.process_template(context) + + assert context.fragment["Resources"] == {} + + def test_missing_resources_section(self, processor: UpdateReplacePolicyProcessor): + """Test processing template without Resources section.""" + context = TemplateProcessingContext(fragment={}) + + # Should not raise any exception + processor.process_template(context) + + assert "Resources" not in context.fragment + + def test_non_dict_resources_section(self, processor: UpdateReplacePolicyProcessor): + """Test processing template with non-dict Resources section.""" + context = TemplateProcessingContext(fragment={"Resources": "not a dict"}) + + # Should not raise any exception (handled gracefully) + processor.process_template(context) + + def test_non_dict_resource_definition(self, processor: UpdateReplacePolicyProcessor): + """Test processing template with non-dict resource definition.""" + context = TemplateProcessingContext(fragment={"Resources": {"MyBucket": "not a dict"}}) + + # Should not raise any exception (handled gracefully) + processor.process_template(context) + + def test_null_update_replace_policy(self, processor: UpdateReplacePolicyProcessor): + """Test processing resource with null UpdateReplacePolicy.""" + context = TemplateProcessingContext( + fragment={"Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": None}}} + ) + + # Should not raise any exception (None is skipped) + processor.process_template(context) + + # UpdateReplacePolicy should remain None + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] is None + + def test_policy_name_attribute(self, processor: UpdateReplacePolicyProcessor): + """Test that POLICY_NAME attribute is correct.""" + assert processor.POLICY_NAME == "UpdateReplacePolicy" + + def test_unsupported_pseudo_params_attribute(self, processor: UpdateReplacePolicyProcessor): + """Test that UNSUPPORTED_PSEUDO_PARAMS contains AWS::NoValue.""" + assert "AWS::NoValue" in processor.UNSUPPORTED_PSEUDO_PARAMS + + +class TestUpdateReplacePolicyProcessorErrorMessages: + """Tests for UpdateReplacePolicyProcessor error message formatting.""" + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_error_message_includes_resource_logical_id(self, processor: UpdateReplacePolicyProcessor): + """Test that error message includes the resource logical ID.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MySpecialBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "NonExistent"}} + } + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "MySpecialBucket" in str(exc_info.value) + + def test_error_message_includes_policy_name(self, processor: UpdateReplacePolicyProcessor): + """Test that error message includes the policy name.""" + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "NonExistent"}}} + }, + parameter_values={}, + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + assert "UpdateReplacePolicy" in str(exc_info.value) + + +class TestUpdateReplacePolicyWithDeletionPolicy: + """Tests for UpdateReplacePolicyProcessor when used alongside DeletionPolicy.""" + + @pytest.fixture + def processor(self) -> UpdateReplacePolicyProcessor: + """Create an UpdateReplacePolicyProcessor for testing.""" + return UpdateReplacePolicyProcessor() + + def test_resource_with_both_policies(self, processor: UpdateReplacePolicyProcessor): + """Test processing resource with both DeletionPolicy and UpdateReplacePolicy.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": "Retain", + "UpdateReplacePolicy": {"Ref": "PolicyParam"}, + } + } + }, + parameter_values={"PolicyParam": "Snapshot"}, + ) + + processor.process_template(context) + + # UpdateReplacePolicy should be resolved + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Snapshot" + # DeletionPolicy should be unchanged (not processed by this processor) + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == "Retain" + + def test_does_not_affect_deletion_policy(self, processor: UpdateReplacePolicyProcessor): + """Test that UpdateReplacePolicyProcessor does not modify DeletionPolicy.""" + context = TemplateProcessingContext( + fragment={ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": {"Ref": "SomeParam"}, + "UpdateReplacePolicy": "Retain", + } + } + }, + parameter_values={"SomeParam": "Delete"}, + ) + + processor.process_template(context) + + # DeletionPolicy should remain as Ref (not resolved by this processor) + assert context.fragment["Resources"]["MyBucket"]["DeletionPolicy"] == {"Ref": "SomeParam"} + # UpdateReplacePolicy should be unchanged (already a string) + assert context.fragment["Resources"]["MyBucket"]["UpdateReplacePolicy"] == "Retain" + + +# ============================================================================= +# Parametrized Tests for UpdateReplacePolicy Parameter Resolution +# ============================================================================= + + +class TestUpdateReplacePolicyParametrizedTests: + """ + Parametrized tests for UpdateReplacePolicy parameter resolution. + + These tests validate that for any valid policy value (Delete, Retain, Snapshot) + passed as a parameter, the UpdateReplacePolicyProcessor correctly resolves the Ref + to that value. + + **Validates: Requirements 7.1, 7.2** + """ + + @pytest.mark.parametrize( + "policy_value, param_name, resource_id", + [ + ("Delete", "PolicyParam", "MyBucketResource"), + ("Retain", "RetentionPolicy", "DataStore1"), + ("Snapshot", "VolPolicy", "EbsVolume"), + ], + ) + def test_update_replace_policy_resolves_parameter_ref_to_value( + self, + policy_value: str, + param_name: str, + resource_id: str, + ): + """ + For any valid policy value (Delete, Retain, Snapshot) passed as a parameter, + the UpdateReplacePolicyProcessor correctly resolves the Ref to that value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = UpdateReplacePolicyProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {resource_id: {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": param_name}}} + }, + parameter_values={param_name: policy_value}, + ) + + processor.process_template(context) + + assert context.fragment["Resources"][resource_id]["UpdateReplacePolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_value, param_name", + [ + ("Delete", "DeletePolicyParam"), + ("Retain", "RetainPolicyParam"), + ("Snapshot", "SnapshotPolicyParam"), + ], + ) + def test_update_replace_policy_resolves_parameter_default_value( + self, + policy_value: str, + param_name: str, + ): + """ + For any valid policy value set as a parameter default, the + UpdateReplacePolicyProcessor correctly resolves the Ref to that value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = UpdateReplacePolicyProcessor() + context = TemplateProcessingContext( + fragment={ + "Resources": {"MyResource": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": param_name}}} + }, + parameter_values={}, + parsed_template=ParsedTemplate( + parameters={param_name: {"Type": "String", "Default": policy_value}}, resources={} + ), + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyResource"]["UpdateReplacePolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_value, num_resources", + [ + ("Delete", 1), + ("Retain", 3), + ("Snapshot", 5), + ], + ) + def test_update_replace_policy_resolves_same_parameter_across_multiple_resources( + self, + policy_value: str, + num_resources: int, + ): + """ + For any valid policy value passed as a parameter, the UpdateReplacePolicyProcessor + correctly resolves the Ref to that value across multiple resources. + + **Validates: Requirements 7.1, 7.2** + """ + processor = UpdateReplacePolicyProcessor() + resources = {} + for i in range(num_resources): + resources[f"Resource{i}"] = {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": "PolicyParam"}} + + context = TemplateProcessingContext( + fragment={"Resources": resources}, parameter_values={"PolicyParam": policy_value} + ) + + processor.process_template(context) + + for i in range(num_resources): + assert context.fragment["Resources"][f"Resource{i}"]["UpdateReplacePolicy"] == policy_value + + @pytest.mark.parametrize( + "policy_values", + [ + ["Delete"], + ["Retain", "Snapshot"], + ["Delete", "Retain", "Snapshot"], + ], + ) + def test_update_replace_policy_resolves_different_parameters_to_different_values( + self, + policy_values: List[str], + ): + """ + For any set of valid policy values passed as different parameters, the + UpdateReplacePolicyProcessor correctly resolves each Ref to its respective value. + + **Validates: Requirements 7.1, 7.2** + """ + processor = UpdateReplacePolicyProcessor() + resources = {} + parameter_values = {} + + for i, policy_value in enumerate(policy_values): + param_name = f"PolicyParam{i}" + resources[f"Resource{i}"] = {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": {"Ref": param_name}} + parameter_values[param_name] = policy_value + + context = TemplateProcessingContext(fragment={"Resources": resources}, parameter_values=parameter_values) + + processor.process_template(context) + + for i, policy_value in enumerate(policy_values): + assert context.fragment["Resources"][f"Resource{i}"]["UpdateReplacePolicy"] == policy_value + + @pytest.mark.parametrize("policy_value", ["Delete", "Retain", "Snapshot"]) + def test_update_replace_policy_string_value_unchanged( + self, + policy_value: str, + ): + """ + For any valid policy value already set as a string, the UpdateReplacePolicyProcessor + leaves it unchanged. + + **Validates: Requirements 7.1, 7.2** + """ + processor = UpdateReplacePolicyProcessor() + context = TemplateProcessingContext( + fragment={"Resources": {"MyResource": {"Type": "AWS::S3::Bucket", "UpdateReplacePolicy": policy_value}}} + ) + + processor.process_template(context) + + assert context.fragment["Resources"]["MyResource"]["UpdateReplacePolicy"] == policy_value From 2ce0caa1572188c53163f1f2134c875421bc6b2d Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 13 Feb 2026 23:38:07 +0000 Subject: [PATCH 14/21] feat: integrate language extensions into SAM CLI Wire the language extensions library into SAM CLI with two-phase architecture: - Phase 1: expand_language_extensions() -> LanguageExtensionResult - Phase 2: SamTranslatorWrapper.run_plugins() (SAM transform only) Key components: - expand_language_extensions() canonical entry point with template-level cache keyed on (path, mtime, params_hash) - SamTranslatorWrapper receives pre-expanded template (Phase 2 only) - SamLocalStackProvider.get_stacks() calls expand_language_extensions() - SamTemplateValidator calls expand_language_extensions() - DynamicArtifactProperty dataclass for Mappings transformation - Fn::ForEach guards in artifact_exporter, normalizer, cdk/utils - clear_expansion_cache() for warm container file change events --- samcli/commands/_utils/template.py | 262 +++- samcli/commands/validate/validate.py | 6 +- .../sam_integration.py | 823 ++++++++++++ samcli/lib/iac/cdk/utils.py | 10 +- .../resources/resource_mapping_producer.py | 1 + samcli/lib/package/artifact_exporter.py | 14 +- samcli/lib/providers/provider.py | 10 +- samcli/lib/providers/sam_base_provider.py | 28 +- samcli/lib/providers/sam_function_provider.py | 7 + samcli/lib/providers/sam_stack_provider.py | 37 +- .../samlib/resource_metadata_normalizer.py | 4 + samcli/lib/samlib/wrapper.py | 198 ++- .../lib/translate/sam_template_validator.py | 27 +- samcli/lib/warnings/sam_cli_warning.py | 6 +- tests/unit/commands/_utils/test_template.py | 469 +++++++ .../test_template_language_extensions.py | 329 +++++ .../local/lib/test_sam_base_provider.py | 4 +- ..._template_validator_language_extensions.py | 244 ++++ .../test_phase_separation.py | 904 +++++++++++++ .../test_property_tests.py | 1126 ++++++++++++++++ .../test_sam_integration.py | 1083 +++++++++++++++ .../test_template_cache.py | 504 +++++++ .../test_intrinsic_resolver.py | 1 + .../test_intrinsics_symbol_table.py | 5 + ..._sam_stack_provider_language_extensions.py | 155 +++ tests/unit/lib/samlib/test_wrapper.py | 1180 ++++++++++++++++- .../test_wrapper_language_extensions.py | 429 ++++++ 27 files changed, 7794 insertions(+), 72 deletions(-) create mode 100644 samcli/lib/cfn_language_extensions/sam_integration.py create mode 100644 tests/unit/commands/_utils/test_template_language_extensions.py create mode 100644 tests/unit/commands/validate/lib/test_sam_template_validator_language_extensions.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_phase_separation.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_property_tests.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_sam_integration.py create mode 100644 tests/unit/lib/cfn_language_extensions/test_template_cache.py create mode 100644 tests/unit/lib/providers/test_sam_stack_provider_language_extensions.py create mode 100644 tests/unit/lib/samlib/test_wrapper_language_extensions.py diff --git a/samcli/commands/_utils/template.py b/samcli/commands/_utils/template.py index aa85fe3350d..cb9599f8ac2 100644 --- a/samcli/commands/_utils/template.py +++ b/samcli/commands/_utils/template.py @@ -24,6 +24,9 @@ ) from samcli.yamlhelper import yaml_dump, yaml_parse +# Fn::ForEach structure requires exactly 3 elements: [loop_variable, collection, output_template] +FOREACH_REQUIRED_ELEMENTS = 3 + class TemplateNotFoundException(UserException): pass @@ -148,7 +151,15 @@ def _update_relative_paths(template_dict, original_root, new_root): properties[path_prop_name] = updated_path - for _, resource in template_dict.get("Resources", {}).items(): + for resource_key, resource in template_dict.get("Resources", {}).items(): + # Handle Fn::ForEach blocks - update paths inside them + if resource_key.startswith("Fn::ForEach::"): + _update_foreach_relative_paths(resource, original_root, new_root) + continue + + if not isinstance(resource, dict): + continue + resource_type = resource.get("Type") if resource_type not in RESOURCES_WITH_LOCAL_PATHS: @@ -198,6 +209,12 @@ def _update_relative_paths(template_dict, original_root, new_root): continue metadata[ASSET_PATH_METADATA_KEY] = updated_path + # Update relative paths in SAM-generated Mappings sections. + # When sam build generates Mappings for dynamic artifact properties (e.g., SAMCodeUriFunctions), + # the values are relative paths to build artifacts. These paths need to be adjusted when the + # template is moved from the source directory to the build output directory. + _update_sam_mappings_relative_paths(template_dict.get("Mappings", {}), original_root, new_root) + # AWS::Includes can be anywhere within the template dictionary. Hence we need to recurse through the # dictionary in a separate method to find and update relative paths in there template_dict = _update_aws_include_relative_path(template_dict, original_root, new_root) @@ -205,6 +222,137 @@ def _update_relative_paths(template_dict, original_root, new_root): return template_dict +def _update_foreach_relative_paths(foreach_value, original_root, new_root): + """ + Update relative paths in resources defined inside a Fn::ForEach block. + + Fn::ForEach structure: [loop_variable, collection, output_template] + The output_template contains resource definitions that may have relative paths. + + This function handles nested Fn::ForEach blocks recursively. + + Parameters + ---------- + foreach_value : list + The value of the Fn::ForEach block (should be a list with 3 elements) + original_root : str + Path to the directory where all paths were originally set relative to + new_root : str + Path to the new directory that all paths set relative to after this method completes + """ + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + return + + # The third element is the output template containing resource definitions + output_template = foreach_value[2] + if not isinstance(output_template, dict): + return + + # Check each resource definition in the output template + for resource_key, resource_def in output_template.items(): + # Handle nested Fn::ForEach blocks recursively + if resource_key.startswith("Fn::ForEach::"): + _update_foreach_relative_paths(resource_def, original_root, new_root) + continue + + if not isinstance(resource_def, dict): + continue + + resource_type = resource_def.get("Type") + if resource_type not in RESOURCES_WITH_LOCAL_PATHS: + continue + + for path_prop_name in RESOURCES_WITH_LOCAL_PATHS[resource_type]: + properties = resource_def.get("Properties", {}) + + if ( + resource_type in [AWS_SERVERLESS_FUNCTION, AWS_LAMBDA_FUNCTION] + and properties.get("PackageType", ZIP) == IMAGE + ): + if not properties.get("ImageUri"): + continue + resolved_image_archive_path = _resolve_relative_to(properties.get("ImageUri"), original_root, new_root) + if not resolved_image_archive_path or not pathlib.Path(resolved_image_archive_path).is_file(): + continue + + # SAM GraphQLApi has many instances of CODE_ARTIFACT_PROPERTY and all of them must be updated + if resource_type == AWS_SERVERLESS_GRAPHQLAPI and path_prop_name == graphql_api.CODE_ARTIFACT_PROPERTY: + paths_values = graphql_api.find_all_paths_and_values(path_prop_name, properties) + for property_path, property_value in paths_values: + updated_path = _resolve_relative_to(property_value, original_root, new_root) + if not updated_path: + continue + set_value_from_jmespath(properties, property_path, updated_path) + + path = jmespath.search(path_prop_name, properties) + updated_path = _resolve_relative_to(path, original_root, new_root) + + if not updated_path: + continue + + set_value_from_jmespath(properties, path_prop_name, updated_path) + + metadata = resource_def.get("Metadata", {}) + if ASSET_PATH_METADATA_KEY in metadata: + path = metadata.get(ASSET_PATH_METADATA_KEY, "") + updated_path = _resolve_relative_to(path, original_root, new_root) + if not updated_path: + continue + metadata[ASSET_PATH_METADATA_KEY] = updated_path + + +def _update_sam_mappings_relative_paths(mappings, original_root, new_root): + """ + Update relative paths in SAM-generated Mappings sections. + + When sam build generates Mappings for dynamic artifact properties (e.g., Fn::ForEach + with dynamic CodeUri), the Mapping values contain relative paths to build artifacts. + These paths need to be adjusted when the template is moved from the source directory + to the build output directory. + + SAM-generated Mappings follow the naming convention SAM{PropertyName}{LoopName} + (e.g., SAMCodeUriFunctions). Each entry maps a collection value to a dict containing + the artifact property name and its path value. + + Parameters + ---------- + mappings : dict + The Mappings section of the template (will be modified in place) + original_root : str + Path to the directory where all paths were originally set relative to + new_root : str + Path to the new directory that all paths set relative to after this method completes + """ + if not isinstance(mappings, dict): + return + + for mapping_name, mapping_entries in mappings.items(): + # Only process SAM-generated Mappings (prefixed with "SAM") + if not mapping_name.startswith("SAM"): + continue + + if not isinstance(mapping_entries, dict): + continue + + for _key, value_dict in mapping_entries.items(): + if not isinstance(value_dict, dict): + continue + + for prop_name, prop_value in value_dict.items(): + updated_path = _resolve_relative_to(prop_value, original_root, new_root) + if not updated_path: + continue + + # For ImageUri properties, only update if the resolved path points to + # an actual local file (e.g., a .tar.gz image archive). Docker image + # references like "my-image:latest" are not local paths and should not + # be rewritten with relative path prefixes. + if prop_name == "ImageUri" and not pathlib.Path(updated_path).is_file(): + continue + + value_dict[prop_name] = updated_path + + def _update_aws_include_relative_path(template_dict, original_root, new_root): """ Update relative paths in "AWS::Include" directive. This directive can be present at any part of the template, @@ -303,7 +451,16 @@ def get_template_artifacts_format(template_file): packageable_resources = get_packageable_resource_paths() artifacts = [] - for _, resource in template_dict.get("Resources", {}).items(): + for resource_key, resource in template_dict.get("Resources", {}).items(): + # Handle Fn::ForEach blocks - look inside them for resources + if resource_key.startswith("Fn::ForEach::"): + foreach_artifacts = _get_artifacts_from_foreach(resource, packageable_resources) + artifacts.extend(foreach_artifacts) + continue + + if not isinstance(resource, dict): + continue + # First check if the resources are part of package-able resource types. if resource.get("Type") in packageable_resources.keys(): # Flatten list of locations per resource type. @@ -317,6 +474,53 @@ def get_template_artifacts_format(template_file): return artifacts +def _get_artifacts_from_foreach(foreach_value, packageable_resources): + """ + Extract artifact formats from resources defined inside a Fn::ForEach block. + + Fn::ForEach structure: [loop_variable, collection, output_template] + The output_template contains resource definitions that will be expanded. + + This function handles nested Fn::ForEach blocks recursively. + + :param foreach_value: The value of the Fn::ForEach block (should be a list with 3 elements) + :param packageable_resources: Dict of packageable resource types and their paths + :return: list of artifact formats found in the ForEach block + """ + artifacts = [] + + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + return artifacts + + # The third element is the output template containing resource definitions + output_template = foreach_value[2] + if not isinstance(output_template, dict): + return artifacts + + # Check each resource definition in the output template + for resource_key, resource_def in output_template.items(): + # Handle nested Fn::ForEach blocks recursively + if resource_key.startswith("Fn::ForEach::"): + nested_artifacts = _get_artifacts_from_foreach(resource_def, packageable_resources) + artifacts.extend(nested_artifacts) + continue + + if not isinstance(resource_def, dict): + continue + + resource_type = resource_def.get("Type") + if resource_type in packageable_resources.keys(): + # Flatten list of locations per resource type + locations = list(itertools.chain(*packageable_resources.get(resource_type))) + for location in locations: + properties = resource_def.get("Properties", {}) + # Search for package-able location within resource properties + if jmespath.search(location, properties): + artifacts.append(properties.get("PackageType", ZIP)) + + return artifacts + + def get_template_function_resource_ids(template_file, artifact): """ Get a list of function logical ids from template file. @@ -331,9 +535,63 @@ def get_template_function_resource_ids(template_file, artifact): template_dict = get_template_data(template_file=template_file) _function_resource_ids = [] for resource_id, resource in template_dict.get("Resources", {}).items(): + # Handle Fn::ForEach blocks - look inside them for function resources + # Note: We can't return the actual expanded resource IDs here since we don't + # have the collection values resolved. We return a placeholder to indicate + # that functions exist inside the ForEach block. + if resource_id.startswith("Fn::ForEach::"): + foreach_functions = _get_function_ids_from_foreach(resource, artifact) + if foreach_functions: + # Return the ForEach key as a placeholder - the actual function IDs + # will be determined after language extensions are processed + _function_resource_ids.append(resource_id) + continue + + if not isinstance(resource, dict): + continue if resource.get("Properties", {}).get("PackageType", ZIP) == artifact and resource.get("Type") in [ AWS_SERVERLESS_FUNCTION, AWS_LAMBDA_FUNCTION, ]: _function_resource_ids.append(resource_id) return _function_resource_ids + + +def _get_function_ids_from_foreach(foreach_value, artifact): + """ + Check if a Fn::ForEach block contains function resources with the specified artifact type. + + This function handles nested Fn::ForEach blocks recursively. + + :param foreach_value: The value of the Fn::ForEach block (should be a list with 3 elements) + :param artifact: artifact of type IMAGE or ZIP + :return: list of resource template keys that are functions (not expanded IDs) + """ + function_keys = [] + + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + return function_keys + + # The third element is the output template containing resource definitions + output_template = foreach_value[2] + if not isinstance(output_template, dict): + return function_keys + + # Check each resource definition in the output template + for resource_key, resource_def in output_template.items(): + # Handle nested Fn::ForEach blocks recursively + if resource_key.startswith("Fn::ForEach::"): + nested_functions = _get_function_ids_from_foreach(resource_def, artifact) + function_keys.extend(nested_functions) + continue + + if not isinstance(resource_def, dict): + continue + + resource_type = resource_def.get("Type") + package_type = resource_def.get("Properties", {}).get("PackageType", ZIP) + + if package_type == artifact and resource_type in [AWS_SERVERLESS_FUNCTION, AWS_LAMBDA_FUNCTION]: + function_keys.append(resource_key) + + return function_keys diff --git a/samcli/commands/validate/validate.py b/samcli/commands/validate/validate.py index cf317b8bfe7..ae311836ad2 100644 --- a/samcli/commands/validate/validate.py +++ b/samcli/commands/validate/validate.py @@ -88,7 +88,11 @@ def do_cli(ctx, template, lint): else: iam_client = boto3.client("iam") validator = SamTemplateValidator( - sam_template.deserialized, ManagedPolicyLoader(iam_client), profile=ctx.profile, region=ctx.region + sam_template.deserialized, + ManagedPolicyLoader(iam_client), + profile=ctx.profile, + region=ctx.region, + template_path=template, ) try: diff --git a/samcli/lib/cfn_language_extensions/sam_integration.py b/samcli/lib/cfn_language_extensions/sam_integration.py new file mode 100644 index 00000000000..ce665b6d13b --- /dev/null +++ b/samcli/lib/cfn_language_extensions/sam_integration.py @@ -0,0 +1,823 @@ +""" +SAM Translator and SAM CLI integration for CloudFormation Language Extensions. + +This module provides integration points for the AWS SAM ecosystem: +1. SAMLanguageExtensionsPlugin - Plugin for SAM Translator +2. process_template_for_sam_cli - Function for SAM CLI commands + +The integration enables processing of language extensions (Fn::ForEach, +Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) before +SAM transforms are applied. + +Requirements: + - 14.1-14.6: SAM Translator plugin integration + - 15.1-15.6: SAM CLI integration +""" + +import copy +import logging +import os +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +from samcli.lib.cfn_language_extensions.api import create_default_pipeline +from samcli.lib.cfn_language_extensions.models import ( + PseudoParameterValues, + ResolutionMode, + TemplateProcessingContext, +) + +LOG = logging.getLogger(__name__) + +# Transform name for AWS Language Extensions +AWS_LANGUAGE_EXTENSIONS_TRANSFORM = "AWS::LanguageExtensions" + +# Template-level cache for language extension expansion results. +# Key: (template_path, file_mtime, parameter_values_hash) +# Value: LanguageExtensionResult +# Scoped to a single CLI process; cleared by clear_expansion_cache() +# in warm container refresh and test fixtures. +_expansion_cache: Dict[Tuple[str, float, int], "LanguageExtensionResult"] = {} + + +@dataclass(frozen=True) +class LanguageExtensionResult: + """ + Result of expanding CloudFormation Language Extensions in a template. + + This dataclass carries all Phase 1 outputs so that callers don't need + to re-derive them. + + Attributes + ---------- + expanded_template : Dict[str, Any] + The template with language extensions resolved (Fn::ForEach expanded, etc.) + original_template : Dict[str, Any] + Deep copy of the original template before expansion, preserving Fn::ForEach structure + dynamic_artifact_properties : List + List of DynamicArtifactProperty instances detected in Fn::ForEach blocks + had_language_extensions : bool + True if the template contained the AWS::LanguageExtensions transform + """ + + expanded_template: Dict[str, Any] + original_template: Dict[str, Any] + dynamic_artifact_properties: List = field(default_factory=list) + had_language_extensions: bool = False + + +def check_using_language_extension(template: Optional[Dict]) -> bool: + """ + Check if language extensions are set in the template's Transform. + + This is the canonical location for this check. SamTranslatorWrapper and + PackageContext maintain backward-compatible aliases that delegate here. + + Parameters + ---------- + template : dict or None + The template to check + + Returns + ------- + bool + True if language extensions are set in the template, False otherwise + """ + if template is None: + return False + transform = template.get("Transform") + if transform: + if isinstance(transform, str) and transform.startswith(AWS_LANGUAGE_EXTENSIONS_TRANSFORM): + return True + if isinstance(transform, list): + for transform_instance in transform: + if not isinstance(transform_instance, str): + continue + if transform_instance.startswith(AWS_LANGUAGE_EXTENSIONS_TRANSFORM): + return True + return False + + +def _build_pseudo_parameters( + parameter_values: Optional[Dict[str, Any]], +) -> Optional[PseudoParameterValues]: + """ + Build PseudoParameterValues from a parameter_values dictionary. + + Extracts AWS pseudo-parameters (AWS::Region, AWS::AccountId, etc.) from + the parameter_values dict and returns a PseudoParameterValues instance. + + Parameters + ---------- + parameter_values : dict or None + Dictionary that may contain pseudo-parameter keys + + Returns + ------- + PseudoParameterValues or None + The pseudo parameters if any are present, None otherwise + """ + if not parameter_values: + return None + + region = parameter_values.get("AWS::Region") + account_id = parameter_values.get("AWS::AccountId") + stack_name = parameter_values.get("AWS::StackName") + stack_id = parameter_values.get("AWS::StackId") + partition = parameter_values.get("AWS::Partition") + url_suffix = parameter_values.get("AWS::URLSuffix") + + if any([region, account_id, stack_name, stack_id, partition, url_suffix]): + return PseudoParameterValues( + region=str(region) if region else "", + account_id=str(account_id) if account_id else "", + stack_name=str(stack_name) if stack_name else None, + stack_id=str(stack_id) if stack_id else None, + partition=str(partition) if partition else None, + url_suffix=str(url_suffix) if url_suffix else None, + ) + + return None + + +def contains_loop_variable(value: Any, loop_variable: str) -> bool: + """ + Check if a value contains a reference to the loop variable. + + This checks for ${LoopVariable} patterns in strings, {"Ref": LoopVariable} + dicts, and recursively checks nested structures. + + Parameters + ---------- + value : Any + The value to check + loop_variable : str + The loop variable name to look for + + Returns + ------- + bool + True if the value contains the loop variable, False otherwise + """ + if isinstance(value, str): + pattern = r"\$\{" + re.escape(loop_variable) + r"\}" + return bool(re.search(pattern, value)) + elif isinstance(value, dict): + # Check for {"Ref": loop_variable} — used in Fn::FindInMap after build + if "Ref" in value and value["Ref"] == loop_variable: + return True + if "Fn::Sub" in value: + sub_value = value["Fn::Sub"] + if isinstance(sub_value, str): + return contains_loop_variable(sub_value, loop_variable) + elif isinstance(sub_value, list) and len(sub_value) >= 1: + return contains_loop_variable(sub_value[0], loop_variable) + return any(contains_loop_variable(v, loop_variable) for v in value.values()) + elif isinstance(value, list): + return any(contains_loop_variable(item, loop_variable) for item in value) + return False + + +def substitute_loop_variable(template_str: str, loop_variable: str, value: str) -> str: + """ + Substitute the loop variable in a template string with a value. + + Parameters + ---------- + template_str : str + The template string containing ${LoopVariable} patterns + loop_variable : str + The loop variable name to substitute + value : str + The value to substitute + + Returns + ------- + str + The string with the loop variable substituted + """ + pattern = r"\$\{" + re.escape(loop_variable) + r"\}" + return re.sub(pattern, value, template_str) + + +def resolve_collection( + collection_value: Any, + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, +) -> List[str]: + """ + Resolve a Fn::ForEach collection to a list of string values. + + Handles static lists and parameter references. + + Parameters + ---------- + collection_value : Any + The collection value from the Fn::ForEach block + template : dict + The full template dictionary (for resolving parameter references) + parameter_values : dict, optional + Parameter values for resolving !Ref to parameters + + Returns + ------- + List[str] + The resolved collection values, or empty list if cannot be resolved + """ + if isinstance(collection_value, list): + return [str(item) for item in collection_value if item is not None] + + if isinstance(collection_value, dict): + if "Ref" in collection_value: + param_name = collection_value["Ref"] + return resolve_parameter_collection(param_name, template, parameter_values) + + return [] + + +def resolve_parameter_collection( + param_name: str, + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, +) -> List[str]: + """ + Resolve a parameter reference to a list of string values. + + Parameters + ---------- + param_name : str + The parameter name + template : dict + The full template dictionary + parameter_values : dict, optional + Parameter values (from --parameter-overrides) + + Returns + ------- + List[str] + The resolved collection values, or empty list if cannot be resolved + """ + if parameter_values and param_name in parameter_values: + value = parameter_values[param_name] + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [item.strip() for item in value.split(",")] + + parameters = template.get("Parameters", {}) + if param_name in parameters: + param_def = parameters[param_name] + if isinstance(param_def, dict): + default_value = param_def.get("Default") + if isinstance(default_value, list): + return [str(item) for item in default_value] + if isinstance(default_value, str): + return [item.strip() for item in default_value.split(",")] + + return [] + + +def detect_foreach_dynamic_properties( + foreach_key: str, + foreach_value: Any, + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + outer_loops: Optional[List[Tuple[str, str, List[str]]]] = None, +) -> List: + """ + Detect dynamic artifact properties in a single Fn::ForEach block. + + Recursively descends into nested Fn::ForEach blocks within the body, + tracking enclosing loops in ``outer_loops`` so that compound Mapping + keys can be generated when the artifact property references multiple + loop variables. + + Parameters + ---------- + foreach_key : str + The Fn::ForEach key (e.g., "Fn::ForEach::Services") + foreach_value : Any + The Fn::ForEach value (should be a list with 3 elements) + template : dict + The full template dictionary (for resolving parameter references) + parameter_values : dict, optional + Parameter values for resolving collections + outer_loops : list of tuples, optional + Enclosing loop info accumulated during recursion. + Each tuple is ``(foreach_key, loop_variable, collection)``. + + Returns + ------- + List[DynamicArtifactProperty] + List of dynamic artifact property locations found in this ForEach block + (including any nested blocks) + """ + from samcli.lib.samlib.wrapper import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES, DynamicArtifactProperty + + foreach_required_elements = 3 + dynamic_properties: List = [] + + if outer_loops is None: + outer_loops = [] + + if not isinstance(foreach_value, list) or len(foreach_value) != foreach_required_elements: + return dynamic_properties + + loop_variable = foreach_value[0] + collection_value = foreach_value[1] + output_template = foreach_value[2] + + if not isinstance(loop_variable, str): + return dynamic_properties + + if not isinstance(output_template, dict): + return dynamic_properties + + loop_name = foreach_key.replace("Fn::ForEach::", "") + + # Check if collection is a parameter reference + collection_is_parameter_ref = False + collection_parameter_name: Optional[str] = None + if isinstance(collection_value, dict) and "Ref" in collection_value: + param_name = collection_value["Ref"] + parameters = template.get("Parameters", {}) + if param_name in parameters: + collection_is_parameter_ref = True + collection_parameter_name = param_name + + collection = resolve_collection(collection_value, template, parameter_values) + if not collection: + return dynamic_properties + + # Build the outer_loops list for any nested calls + current_outer_loops = outer_loops + [(foreach_key, loop_variable, collection)] + + for resource_key, resource_def in output_template.items(): + # Recurse into nested Fn::ForEach blocks + if isinstance(resource_key, str) and resource_key.startswith("Fn::ForEach::"): + nested_props = detect_foreach_dynamic_properties( + resource_key, resource_def, template, parameter_values, outer_loops=current_outer_loops + ) + dynamic_properties.extend(nested_props) + continue + + if not isinstance(resource_def, dict): + continue + + resource_type = resource_def.get("Type") + if not isinstance(resource_type, str): + continue + + artifact_properties = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type) + if not artifact_properties: + continue + + properties = resource_def.get("Properties", {}) + if not isinstance(properties, dict): + continue + + for prop_name in artifact_properties: + prop_value = properties.get(prop_name) + if prop_value is not None: + if contains_loop_variable(prop_value, loop_variable): + dynamic_properties.append( + DynamicArtifactProperty( + foreach_key=foreach_key, + loop_name=loop_name, + loop_variable=loop_variable, + collection=collection, + resource_key=resource_key, + resource_type=resource_type, + property_name=prop_name, + property_value=prop_value, + collection_is_parameter_ref=collection_is_parameter_ref, + collection_parameter_name=collection_parameter_name, + outer_loops=list(outer_loops), + ) + ) + + return dynamic_properties + + +def detect_dynamic_artifact_properties( + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, +) -> List: + """ + Detect dynamic artifact properties in Fn::ForEach blocks. + + Scans all Fn::ForEach blocks in the Resources section and identifies + any generated resources with packageable artifact properties that use + the loop variable. + + Parameters + ---------- + template : dict + The template dictionary to scan + parameter_values : dict, optional + Parameter values for resolving collections + + Returns + ------- + List[DynamicArtifactProperty] + List of dynamic artifact property locations found in the template + """ + dynamic_properties: List = [] + resources = template.get("Resources", {}) + if not isinstance(resources, dict): + return dynamic_properties + + for key, value in resources.items(): + if key.startswith("Fn::ForEach::"): + props = detect_foreach_dynamic_properties(key, value, template, parameter_values) + dynamic_properties.extend(props) + + return dynamic_properties + + +def _hash_params(parameter_values: Optional[Dict[str, Any]]) -> int: + """ + Compute a stable hash for parameter values to use as part of the cache key. + + Parameters + ---------- + parameter_values : dict, optional + Template parameter values (may contain nested dicts/lists from + intrinsic functions like {"Ref": "..."}) + + Returns + ------- + int + Hash of the parameter values + """ + if not parameter_values: + return hash(()) + # Use json.dumps with sort_keys for a stable, hashable representation + # that handles nested dicts/lists from intrinsic functions. + import json + + return hash(json.dumps(parameter_values, sort_keys=True, default=str)) + + +def clear_expansion_cache() -> None: + """ + Clear the template-level expansion cache. + + Called by warm container refresh (sam local start-api / start-lambda) + when template files change on disk, and by test fixtures to ensure + isolation between test cases. + """ + _expansion_cache.clear() + + +def expand_language_extensions( + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + template_path: Optional[str] = None, +) -> LanguageExtensionResult: + """ + Canonical Phase 1 entry point for expanding CloudFormation Language Extensions. + + This function performs all Phase 1 work: + 1. Checks for AWS::LanguageExtensions transform + 2. Deep copies the original template before expansion + 3. Detects dynamic artifact properties in Fn::ForEach blocks + 4. Extracts pseudo-parameters from parameter_values + 5. Calls process_template_for_sam_cli() for expansion + 6. Returns a LanguageExtensionResult with all outputs + + If template_path is provided, results are cached by + (template_path, file_mtime, parameter_values_hash) to avoid redundant + expansions within a single CLI command invocation. + + If the template does not contain the AWS::LanguageExtensions transform, + returns early with had_language_extensions=False and the original template + unchanged. + + Parameters + ---------- + template : dict + The raw template dictionary + parameter_values : dict, optional + Template parameter values (may include pseudo-parameters like AWS::Region) + template_path : str, optional + Path to the template file on disk. When provided, enables caching + keyed on (path, mtime, params_hash). + + Returns + ------- + LanguageExtensionResult + Result containing expanded_template, original_template, + dynamic_artifact_properties, and had_language_extensions flag + + Raises + ------ + InvalidSamDocumentException + If the template contains invalid language extension syntax + """ + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + + # Check cache if template_path is provided + cache_key = None + if template_path: + try: + mtime = os.path.getmtime(template_path) + params_hash = _hash_params(parameter_values) + cache_key = (template_path, mtime, params_hash) + if cache_key in _expansion_cache: + LOG.debug("Cache hit for language extension expansion: %s", template_path) + return _expansion_cache[cache_key] + except OSError: + LOG.debug("Could not stat template file for caching: %s", template_path) + cache_key = None + + if not check_using_language_extension(template): + result = LanguageExtensionResult( + expanded_template=template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + if cache_key is not None: + _expansion_cache[cache_key] = result + return result + + LOG.debug("Expanding CloudFormation Language Extensions (Phase 1)") + + # Deep copy original template before any modifications + original_template = copy.deepcopy(template) + + # Detect dynamic artifact properties before expansion + dynamic_properties = detect_dynamic_artifact_properties(template, parameter_values) + + # Extract pseudo-parameters from parameter_values + pseudo_params = _build_pseudo_parameters(parameter_values) + + try: + expanded_template = process_template_for_sam_cli( + template, + parameter_values=parameter_values, + pseudo_parameters=pseudo_params, + ) + + LOG.debug("Successfully expanded CloudFormation Language Extensions") + + result = LanguageExtensionResult( + expanded_template=expanded_template, + original_template=original_template, + dynamic_artifact_properties=dynamic_properties, + had_language_extensions=True, + ) + + # Store result in cache on miss + if cache_key is not None: + _expansion_cache[cache_key] = result + + return result + + except Exception as e: + from samcli.lib.cfn_language_extensions.exceptions import ( + InvalidTemplateException as LangExtInvalidTemplateException, + ) + + if isinstance(e, LangExtInvalidTemplateException): + LOG.error("Failed to expand CloudFormation Language Extensions: %s", str(e)) + raise InvalidSamDocumentException(str(e)) from e + raise + + +class SAMLanguageExtensionsPlugin: + """ + Plugin for SAM Translator to process language extensions. + + This plugin implements the SAM Translator plugin interface and processes + CloudFormation language extensions before the SAM transform is applied. + + The plugin: + 1. Checks if AWS::LanguageExtensions is in the template's transforms + 2. Processes language extensions in partial resolution mode + 3. Removes AWS::LanguageExtensions from the transform list + 4. Returns the processed template for SAM Translator to continue + + This allows SAM templates to use language extensions like Fn::ForEach + to generate multiple serverless resources. + + Example: + >>> from samtranslator.plugins import LifeCycleEvents + >>> + >>> plugin = SAMLanguageExtensionsPlugin(parameter_values={"Env": "prod"}) + >>> + >>> # Register with SAM Translator + >>> translator.plugins.register(plugin) + + Requirements: + - 14.1: Implement on_before_transform_template method + - 14.2: Check for AWS::LanguageExtensions in transforms + - 14.3: Process language extensions in partial mode + - 14.4: Remove AWS::LanguageExtensions from transform list + - 14.5: Preserve unresolvable refs for SAM processing + - 14.6: Compatible with samtranslator.plugins interface + """ + + def __init__( + self, + parameter_values: Optional[Dict[str, Any]] = None, + pseudo_parameters: Optional[PseudoParameterValues] = None, + ) -> None: + """ + Initialize the SAM Language Extensions plugin. + + Args: + parameter_values: Optional dictionary of parameter values to use + for resolving Ref to parameters. + pseudo_parameters: Optional PseudoParameterValues for resolving + AWS pseudo-parameters (AWS::Region, etc.). + """ + self.parameter_values = parameter_values or {} + self.pseudo_parameters = pseudo_parameters + + def on_before_transform_template(self, template: Dict[str, Any]) -> Dict[str, Any]: + """ + SAM Translator lifecycle hook - called before SAM transform. + + This method is compatible with the samtranslator.plugins interface. + It processes language extensions and removes the AWS::LanguageExtensions + transform from the template before SAM Translator processes it. + + Args: + template: The CloudFormation/SAM template dictionary. + + Returns: + The processed template with language extensions resolved and + AWS::LanguageExtensions removed from transforms. + + Requirements: + - 14.1: Implement on_before_transform_template method + - 14.2: Check for AWS::LanguageExtensions in transforms + - 14.3: Process language extensions in partial mode + - 14.4: Remove AWS::LanguageExtensions from transform list + """ + # Get the transforms from the template + transforms = template.get("Transform", []) + + # Normalize to list for consistent handling + if isinstance(transforms, str): + transforms = [transforms] + elif transforms is None: + transforms = [] + + # Check if AWS::LanguageExtensions is in transforms + if AWS_LANGUAGE_EXTENSIONS_TRANSFORM not in transforms: + # No language extensions to process + return template + + # Process language extensions in partial mode + context = TemplateProcessingContext( + fragment=template.copy(), + parameter_values=self.parameter_values, + pseudo_parameters=self.pseudo_parameters, + resolution_mode=ResolutionMode.PARTIAL, + ) + + pipeline = create_default_pipeline(context) + processed = pipeline.process_template(context) + + # Remove AWS::LanguageExtensions from transforms + new_transforms = [t for t in transforms if t != AWS_LANGUAGE_EXTENSIONS_TRANSFORM] + + # Update the Transform field based on remaining transforms + if len(new_transforms) == 0: + # No transforms left, remove the field + processed.pop("Transform", None) + elif len(new_transforms) == 1: + # Single transform, use string form + processed["Transform"] = new_transforms[0] + else: + # Multiple transforms, use list form + processed["Transform"] = new_transforms + + return processed + + +def process_template_for_sam_cli( + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + pseudo_parameters: Optional[PseudoParameterValues] = None, +) -> Dict[str, Any]: + """ + Process a template for SAM CLI commands. + + This function is designed to be called from SAM CLI's template + processing pipeline (e.g., in SamLocalStackProvider). It processes + language extensions in partial resolution mode, preserving references + that cannot be resolved locally. + + The function: + 1. Creates a processing context with partial resolution mode + 2. Runs the template through the default processing pipeline + 3. Returns the processed template with language extensions resolved + + Unlike the SAMLanguageExtensionsPlugin, this function does NOT remove + the AWS::LanguageExtensions transform from the template. This is because + SAM CLI may need to preserve the transform for deployment. + + Args: + template: The raw template dictionary. + parameter_values: Template parameter values. + pseudo_parameters: AWS pseudo-parameter values. + + Returns: + Processed template with language extensions resolved. + + Example: + >>> template = load_template("template.yaml") + >>> params = {"Environment": "prod"} + >>> pseudo = PseudoParameterValues( + ... region="us-east-1", + ... account_id="123456789012" + ... ) + >>> processed = process_template_for_sam_cli(template, params, pseudo) + + Requirements: + - 15.1: Accept template, parameters, and pseudo-parameters + - 15.2: Process in partial resolution mode + - 15.3: Return processed template + - 15.4: Preserve unresolvable references + - 15.5: Compatible with SAM CLI template processing + """ + context = TemplateProcessingContext( + fragment=template.copy(), + parameter_values=parameter_values or {}, + pseudo_parameters=pseudo_parameters, + resolution_mode=ResolutionMode.PARTIAL, + ) + + pipeline = create_default_pipeline(context) + return pipeline.process_template(context) + + +def process_nested_stacks( + template: Dict[str, Any], + parameter_values: Optional[Dict[str, Any]] = None, + pseudo_parameters: Optional[PseudoParameterValues] = None, + nested_templates: Optional[Dict[str, Dict[str, Any]]] = None, +) -> Dict[str, Dict[str, Any]]: + """ + Process a template and its nested stacks. + + This function processes the main template and any nested stack templates + independently. Each nested stack is processed with its own parameter + values while sharing the same pseudo-parameters. + + Args: + template: The main template dictionary. + parameter_values: Parameter values for the main template. + pseudo_parameters: AWS pseudo-parameter values (shared across stacks). + nested_templates: Dictionary mapping nested stack logical IDs to + their template dictionaries. + + Returns: + Dictionary mapping logical IDs to processed templates. + The main template is keyed as "MainTemplate". + + Example: + >>> main_template = load_template("main.yaml") + >>> nested = { + ... "VPCStack": load_template("vpc.yaml"), + ... "DatabaseStack": load_template("database.yaml"), + ... } + >>> results = process_nested_stacks( + ... main_template, + ... parameter_values={"Env": "prod"}, + ... nested_templates=nested + ... ) + >>> processed_main = results["MainTemplate"] + >>> processed_vpc = results["VPCStack"] + + Requirements: + - 15.6: Process each nested stack independently + """ + results: Dict[str, Dict[str, Any]] = {} + + # Process the main template + results["MainTemplate"] = process_template_for_sam_cli( + template, + parameter_values=parameter_values, + pseudo_parameters=pseudo_parameters, + ) + + # Process nested templates if provided + if nested_templates: + for logical_id, nested_template in nested_templates.items(): + # Each nested stack gets its own processing + # Note: In a real implementation, we might extract parameter + # values from the parent stack's AWS::CloudFormation::Stack + # resource properties + results[logical_id] = process_template_for_sam_cli( + nested_template, + parameter_values={}, # Nested stacks have their own params + pseudo_parameters=pseudo_parameters, + ) + + return results diff --git a/samcli/lib/iac/cdk/utils.py b/samcli/lib/iac/cdk/utils.py index e4cab0dfe62..c8aa8e73fcd 100644 --- a/samcli/lib/iac/cdk/utils.py +++ b/samcli/lib/iac/cdk/utils.py @@ -6,6 +6,8 @@ import os from typing import Dict +from samcli.lib.cfn_language_extensions.utils import is_foreach_key + LOG = logging.getLogger(__name__) CDK_METADATA_TYPE_VALUE = "AWS::CDK::Metadata" CDK_PATH_METADATA_KEY = "aws:cdk:path" @@ -42,7 +44,9 @@ def _resource_level_metadata_exists(resources: Dict) -> bool: Dict of resources to look through """ - for _, resource in resources.items(): + for resource_key, resource in resources.items(): + if is_foreach_key(resource_key) or not isinstance(resource, dict): + continue if resource.get("Type", "") == CDK_METADATA_TYPE_VALUE: return True return False @@ -58,7 +62,9 @@ def _cdk_path_metadata_exists(resources: Dict) -> bool: Dict of resources to look through """ - for _, resource in resources.items(): + for resource_key, resource in resources.items(): + if is_foreach_key(resource_key) or not isinstance(resource, dict): + continue metadata = resource.get("Metadata", {}) if metadata and CDK_PATH_METADATA_KEY in metadata: return True diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 562f145f9a8..967468d0ed0 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -107,6 +107,7 @@ def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Dict[Any, A profile=self.profile, region=self.region, parameter_overrides=self.parameter_overrides, + template_path=self.template_file, ) translated_dict = yaml_parse(validator.get_translated_template_if_valid()) return translated_dict diff --git a/samcli/lib/package/artifact_exporter.py b/samcli/lib/package/artifact_exporter.py index bc38ee4a5e2..0e25f1f12d3 100644 --- a/samcli/lib/package/artifact_exporter.py +++ b/samcli/lib/package/artifact_exporter.py @@ -14,6 +14,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import logging import os from typing import Dict, List, Optional @@ -21,6 +22,7 @@ from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled from samcli.commands.package import exceptions +from samcli.lib.cfn_language_extensions.utils import iter_resources from samcli.lib.package.code_signer import CodeSigner from samcli.lib.package.local_files_utils import get_uploaded_s3_object_name, mktempfile from samcli.lib.package.packageable_resources import ( @@ -50,6 +52,8 @@ from samcli.lib.utils.s3 import parse_s3_url from samcli.yamlhelper import yaml_dump, yaml_parse +LOG = logging.getLogger(__name__) + # NOTE: sriram-mv, A cyclic dependency on `Template` needs to be broken. @@ -250,7 +254,7 @@ def _apply_global_values(self): Intentionally not dealing with Api:DefinitionUri at this point. """ - for _, resource in self.template_dict["Resources"].items(): + for resource_key, resource in iter_resources(self.template_dict): resource_type = resource.get("Type", None) resource_dict = resource.get("Properties", None) @@ -280,7 +284,7 @@ def export(self) -> Dict: if is_experimental_enabled(ExperimentalFlag.PackagePerformance): cache = {} - for resource_logical_id, resource in self.template_dict["Resources"].items(): + for resource_logical_id, resource in iter_resources(self.template_dict): resource_type = resource.get("Type", None) resource_dict = resource.get("Properties", {}) resource_id = ResourceMetadataNormalizer.get_resource_id(resource, resource_logical_id) @@ -306,7 +310,7 @@ def delete(self, retain_resources: List): self._apply_global_values() - for resource_id, resource in self.template_dict["Resources"].items(): + for resource_id, resource in iter_resources(self.template_dict): resource_type = resource.get("Type", None) resource_dict = resource.get("Properties", {}) resource_deletion_policy = resource.get("DeletionPolicy", None) @@ -331,7 +335,7 @@ def get_ecr_repos(self): return ecr_repos self._apply_global_values() - for resource_id, resource in self.template_dict["Resources"].items(): + for resource_id, resource in iter_resources(self.template_dict): resource_type = resource.get("Type", None) resource_dict = resource.get("Properties", {}) resource_deletion_policy = resource.get("DeletionPolicy", None) @@ -357,7 +361,7 @@ def get_s3_info(self): self._apply_global_values() - for _, resource in self.template_dict["Resources"].items(): + for resource_key, resource in iter_resources(self.template_dict): resource_type = resource.get("Type", None) resource_dict = resource.get("Properties", {}) diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index 7bf61591c96..eb4e1f957da 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -634,8 +634,11 @@ class Stack: # The parameter overrides for the stack, if there is global_parameter_overrides, # it is also merged into this variable. parameters: Optional[Dict] - # the raw template dict + # the raw template dict (may be processed with language extensions expanded) template_dict: Dict + # the original template dict (before language extensions processing) + # This preserves Fn::ForEach and other language extension constructs + original_template_dict: Optional[Dict] # metadata metadata: Optional[Dict] = None @@ -647,6 +650,7 @@ def __init__( parameters: Optional[Dict], template_dict: Dict, metadata: Optional[Dict[str, str]] = None, + original_template_dict: Optional[Dict] = None, ): self.parent_stack_path = parent_stack_path self.name = name @@ -654,6 +658,9 @@ def __init__( self.parameters = parameters self.template_dict = template_dict self.metadata = metadata + # Store the original template for CloudFormation deployment + # If not provided, use template_dict (for backwards compatibility) + self.original_template_dict = original_template_dict self._resources: Optional[Dict] = None self._raw_resources: Optional[Dict] = None @@ -721,6 +728,7 @@ def __eq__(self, other: Any) -> bool: and self.stack_id == other.stack_id and self.stack_path == other.stack_path and self.template_dict == other.template_dict + and self.original_template_dict == other.original_template_dict ) return False diff --git a/samcli/lib/providers/sam_base_provider.py b/samcli/lib/providers/sam_base_provider.py index c78c1eda82d..4b02e02e08e 100644 --- a/samcli/lib/providers/sam_base_provider.py +++ b/samcli/lib/providers/sam_base_provider.py @@ -5,6 +5,7 @@ import logging from typing import Any, Dict, Iterable, Optional, Union, cast +from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult from samcli.lib.iac.plugins_interfaces import Stack from samcli.lib.intrinsic_resolver.intrinsic_property_resolver import IntrinsicResolver from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable @@ -165,7 +166,10 @@ def _extract_sam_function_imageuri(resource_properties: Dict[str, str], code_pro @staticmethod def get_template( - template_dict: Dict, parameter_overrides: Optional[Dict[str, str]] = None, use_sam_transform: bool = True + template_dict: Dict, + parameter_overrides: Optional[Dict[str, str]] = None, + use_sam_transform: bool = True, + language_extension_result: Optional[LanguageExtensionResult] = None, ) -> Dict: """ Given a SAM template dictionary, return a cleaned copy of the template where SAM plugins have been run @@ -182,6 +186,11 @@ def get_template( use_sam_transform: bool Whether to transform the given template with Serverless Application Model. Default is True + language_extension_result : LanguageExtensionResult, optional + Pre-computed result from expand_language_extensions(). When provided, + avoids redundant deep-copy in SamTranslatorWrapper and carries + original_template and dynamic_artifact_properties through. + Returns ------- dict @@ -190,7 +199,11 @@ def get_template( template_dict = template_dict or {} parameters_values = SamBaseProvider._get_parameter_values(template_dict, parameter_overrides) if template_dict and use_sam_transform: - template_dict = SamTranslatorWrapper(template_dict, parameter_values=parameters_values).run_plugins() + template_dict = SamTranslatorWrapper( + template_dict, + parameter_values=parameters_values, + language_extension_result=language_extension_result, + ).run_plugins() ResourceMetadataNormalizer.normalize(template_dict) resolver = IntrinsicResolver( @@ -205,6 +218,7 @@ def get_resolved_template_dict( template_dict: Stack, parameter_overrides: Optional[Dict[str, str]] = None, normalize_resource_metadata: bool = True, + language_extension_result: Optional[LanguageExtensionResult] = None, ) -> Stack: """ Given a SAM template dictionary, return a cleaned copy of the template where SAM plugins have been run @@ -218,6 +232,10 @@ def get_resolved_template_dict( normalize_resource_metadata: bool flag to normalize resource metadata or not; For package and deploy, we don't need to normalize resource metadata, which usually exists in a CDK-synthed template and is used for build and local testing + language_extension_result : LanguageExtensionResult, optional + Pre-computed result from expand_language_extensions(). When provided, + avoids redundant deep-copy in SamTranslatorWrapper and carries + original_template and dynamic_artifact_properties through. Returns ------- dict @@ -229,7 +247,11 @@ def get_resolved_template_dict( template_dict = template_dict or Stack() parameters_values = SamBaseProvider._get_parameter_values(template_dict, parameter_overrides) if template_dict: - template_dict = SamTranslatorWrapper(template_dict, parameter_values=parameters_values).run_plugins() + template_dict = SamTranslatorWrapper( + template_dict, + parameter_values=parameters_values, + language_extension_result=language_extension_result, + ).run_plugins() if normalize_resource_metadata: ResourceMetadataNormalizer.normalize(template_dict) diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 732afb893ff..02637055e8b 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -1011,7 +1011,14 @@ def _refresh_loaded_functions(self) -> None: Reload the stacks, and lambda functions from template files. Applies the same function filter during refresh. """ + from samcli.lib.cfn_language_extensions.sam_integration import clear_expansion_cache + LOG.debug("A change got detected in one of the stack templates. Reload the lambda function resources") + + # Clear the language extension expansion cache so that changed templates + # are re-expanded with fresh mtime-based cache keys. + clear_expansion_cache() + self._stacks = [] for template_file in self.parent_templates_paths: diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index d27ad19a8fe..6e0158480eb 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -4,10 +4,11 @@ import logging import os -from typing import Dict, Iterator, List, Optional, Tuple, Union, cast +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast from urllib.parse import unquote, urlparse from samcli.commands._utils.template import TemplateNotFoundException, get_template_data +from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult from samcli.lib.providers.exceptions import RemoteStackLocationNotSupported from samcli.lib.providers.provider import Stack, get_full_path from samcli.lib.providers.sam_base_provider import SamBaseProvider @@ -31,6 +32,7 @@ def __init__( parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, use_sam_transform: bool = True, + language_extension_result: Optional[LanguageExtensionResult] = None, ): """ Initialize the class with SAM template data. The SAM template passed to this provider is assumed @@ -55,6 +57,9 @@ def __init__( the template and all its child templates use_sam_transform: bool Whether to transform the given template with Serverless Application Model. Default is True + language_extension_result : LanguageExtensionResult, optional + Pre-computed result from expand_language_extensions(). When provided, + avoids redundant deep-copy in SamTranslatorWrapper. """ self._template_file = template_file @@ -63,6 +68,7 @@ def __init__( template_dict, SamLocalStackProvider.merge_parameter_overrides(parameter_overrides, global_parameter_overrides), use_sam_transform=use_sam_transform, + language_extension_result=language_extension_result, ) self._resources = self._template_dict.get("Resources", {}) self._global_parameter_overrides = global_parameter_overrides @@ -255,14 +261,36 @@ def get_stacks( message="A template file or a template dict is required but both are missing." ) + # Process language extensions BEFORE creating Stack objects + # This ensures Fn::ForEach and other language extensions are expanded early, + # so that the Stack's template_dict contains expanded resources + merged_params = SamLocalStackProvider.merge_parameter_overrides(parameter_overrides, global_parameter_overrides) + + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable + + parameter_values: Dict[str, Any] = {} + parameter_values.update(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES) + parameter_values.update(merged_params or {}) + + lang_ext_result = expand_language_extensions( + template_dict, parameter_values=parameter_values, template_path=template_file or None + ) + processed_template_dict = lang_ext_result.expanded_template + + # Store the original template (before language extensions processing) for CloudFormation deployment + # This preserves Fn::ForEach and other language extension constructs + original_template = lang_ext_result.original_template if lang_ext_result.had_language_extensions else None + stacks = [ Stack( stack_path, name, template_file, - SamLocalStackProvider.merge_parameter_overrides(parameter_overrides, global_parameter_overrides), - template_dict, + merged_params, + processed_template_dict, metadata, + original_template_dict=original_template, ) ] remote_stack_full_paths: List[str] = [] @@ -270,10 +298,11 @@ def get_stacks( current = SamLocalStackProvider( template_file, stack_path, - template_dict, + processed_template_dict, # Use processed template with expanded language extensions parameter_overrides, global_parameter_overrides, use_sam_transform=use_sam_transform, + language_extension_result=lang_ext_result if lang_ext_result.had_language_extensions else None, ) remote_stack_full_paths.extend(current.remote_stack_full_paths) diff --git a/samcli/lib/samlib/resource_metadata_normalizer.py b/samcli/lib/samlib/resource_metadata_normalizer.py index f1bb101888b..fc9b169d714 100644 --- a/samcli/lib/samlib/resource_metadata_normalizer.py +++ b/samcli/lib/samlib/resource_metadata_normalizer.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Dict +from samcli.lib.cfn_language_extensions.utils import is_foreach_key from samcli.lib.iac.cdk.utils import is_cdk_project from samcli.lib.utils.resources import AWS_CLOUDFORMATION_STACK @@ -62,6 +63,9 @@ def normalize(template_dict, normalize_parameters=False): resources = template_dict.get(RESOURCES_KEY, {}) for logical_id, resource in resources.items(): + if is_foreach_key(logical_id) or not isinstance(resource, dict): + continue + # copy metadata to another variable, change its values and assign it back in the end resource_metadata = deepcopy(resource.get(METADATA_KEY)) or {} diff --git a/samcli/lib/samlib/wrapper.py b/samcli/lib/samlib/wrapper.py index d4c510b5df4..b918702d1e7 100644 --- a/samcli/lib/samlib/wrapper.py +++ b/samcli/lib/samlib/wrapper.py @@ -9,29 +9,106 @@ import copy import functools -from typing import Dict +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from samtranslator.model import ResourceTypeResolver, sam_resources +if TYPE_CHECKING: + pass + +# Packageable resource types and their artifact properties that can be dynamic in Fn::ForEach blocks. +# These properties reference local files/directories that SAM CLI needs to package. +# Dynamic values (using loop variables) are supported via Mappings transformation. +PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES: Dict[str, List[str]] = { + "AWS::Serverless::Function": ["CodeUri", "ImageUri"], + "AWS::Lambda::Function": ["Code"], + "AWS::Serverless::LayerVersion": ["ContentUri"], + "AWS::Lambda::LayerVersion": ["Content"], + "AWS::Serverless::Api": ["DefinitionUri"], + "AWS::Serverless::HttpApi": ["DefinitionUri"], + "AWS::Serverless::StateMachine": ["DefinitionUri"], + "AWS::Serverless::GraphQLApi": ["SchemaUri", "CodeUri"], + "AWS::ApiGateway::RestApi": ["BodyS3Location"], + "AWS::ApiGatewayV2::Api": ["BodyS3Location"], + "AWS::StepFunctions::StateMachine": ["DefinitionS3Location"], +} + + +@dataclass +class DynamicArtifactProperty: + """ + Represents a dynamic artifact property found in a Fn::ForEach block. + + Attributes + ---------- + foreach_key : str + The Fn::ForEach key (e.g., "Fn::ForEach::Services") + loop_name : str + The loop name extracted from the foreach_key (e.g., "Services") + loop_variable : str + The loop variable name (e.g., "Name") + collection : List[str] + The collection values to iterate over (e.g., ["Users", "Orders", "Products"]) + resource_key : str + The resource template key (e.g., "${Name}Service") + resource_type : str + The CloudFormation resource type (e.g., "AWS::Serverless::Function") + property_name : str + The artifact property name (e.g., "CodeUri") + property_value : Any + The original property value with loop variable (e.g., "./services/${Name}") + collection_is_parameter_ref : bool + True if the collection came from a parameter reference (!Ref ParamName), + False if it's a static list. Used to emit warnings about package-time + collection values being fixed. + collection_parameter_name : Optional[str] + The parameter name if collection_is_parameter_ref is True, None otherwise. + """ + + foreach_key: str + loop_name: str + loop_variable: str + collection: List[str] + resource_key: str + resource_type: str + property_name: str + property_value: Any + collection_is_parameter_ref: bool = False + collection_parameter_name: Optional[str] = None + outer_loops: List[Tuple[str, str, List[str]]] = field(default_factory=list) + """ + List of enclosing Fn::ForEach loops for nested ForEach scenarios. + Each tuple is (foreach_key, loop_variable, collection) for an outer loop. + Empty for top-level (non-nested) ForEach blocks. + Used to determine if compound Mapping keys are needed when the dynamic + artifact property references outer loop variables. + """ + + # SAM Translator Library Internal module imports # -from samtranslator.model.exceptions import ( +# These imports are placed here intentionally after the TYPE_CHECKING block +# and constant definitions to avoid circular imports. +from samtranslator.model.exceptions import ( # noqa: E402 InvalidDocumentException, InvalidEventException, InvalidResourceException, InvalidTemplateException, ) -from samtranslator.plugins import LifeCycleEvents -from samtranslator.sdk.resource import SamResource, SamResourceType -from samtranslator.translator.translator import prepare_plugins -from samtranslator.validator.validator import SamTemplateValidator +from samtranslator.plugins import LifeCycleEvents # noqa: E402 +from samtranslator.translator.translator import prepare_plugins # noqa: E402 +from samtranslator.validator.validator import SamTemplateValidator # noqa: E402 + +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException # noqa: E402 -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from .local_uri_plugin import SupportLocalUriPlugin # noqa: E402 -from .local_uri_plugin import SupportLocalUriPlugin +LOG = logging.getLogger(__name__) class SamTranslatorWrapper: - def __init__(self, sam_template, parameter_values=None, offline_fallback=True): + def __init__(self, sam_template, parameter_values=None, offline_fallback=True, language_extension_result=None): """ Parameters @@ -42,6 +119,10 @@ def __init__(self, sam_template, parameter_values=None, offline_fallback=True): SAM Template parameters (must contain psuedo and default parameters) offline_fallback bool: Set it to True to make the translator work entirely offline, if internet is not available + language_extension_result : LanguageExtensionResult, optional + Pre-computed result from expand_language_extensions(). When provided, + original_template and dynamic_artifact_properties are taken from this + result instead of being computed internally. """ self.local_uri_plugin = SupportLocalUriPlugin() self.parameter_values = parameter_values @@ -52,8 +133,30 @@ def __init__(self, sam_template, parameter_values=None, offline_fallback=True): self._sam_template = sam_template self._offline_fallback = offline_fallback + self._language_extension_result = language_extension_result + + if language_extension_result is not None: + # Use pre-computed Phase 1 results + self._original_template = language_extension_result.original_template + self._dynamic_artifact_properties: List[DynamicArtifactProperty] = list( + language_extension_result.dynamic_artifact_properties + ) + else: + # Preserve the original template with a deep copy for CloudFormation deployment + # This ensures Fn::ForEach and other language extensions remain intact + self._original_template = copy.deepcopy(sam_template) + # Dynamic artifact properties detected in Fn::ForEach blocks + # These will be handled via Mappings transformation during sam package + self._dynamic_artifact_properties: List[DynamicArtifactProperty] = [] def run_plugins(self, convert_local_uris=True): + """ + Run SAM Translator plugins on the template (Phase 2 only). + + This method assumes it receives an already-expanded template — language + extension expansion (Phase 1) should have been performed by the caller + via expand_language_extensions() before constructing this wrapper. + """ template_copy = self.template additional_plugins = [] @@ -66,9 +169,6 @@ def run_plugins(self, convert_local_uris=True): additional_plugins, parameters=self.parameter_values if self.parameter_values else {} ) - # Temporarily disabling validation for DeletionPolicy and UpdateReplacePolicy when language extensions are set - self._patch_language_extensions() - try: parser.parse(template_copy, all_plugins) # parse() will run all configured plugins except InvalidDocumentException as e: @@ -82,42 +182,60 @@ def run_plugins(self, convert_local_uris=True): def template(self): return copy.deepcopy(self._sam_template) - def _patch_language_extensions(self) -> None: + def get_original_template(self) -> Dict[str, Any]: """ - Monkey patch SamResource.valid function to exclude checking DeletionPolicy - and UpdateReplacePolicy when language extensions are set + Get the original unexpanded template for CloudFormation deployment. + + This method returns a deep copy of the original template that was passed + to the constructor, preserving Fn::ForEach and other language extension + constructs intact. This is used when the template needs to be sent to + CloudFormation, which will process the AWS::LanguageExtensions transform + server-side. + + Returns + ------- + dict + A deep copy of the original template with language extensions preserved """ - template_copy = self.template - if self._check_using_language_extension(template_copy): + return copy.deepcopy(self._original_template) + + def get_dynamic_artifact_properties(self) -> List[DynamicArtifactProperty]: + """ + Get the list of dynamic artifact properties detected in Fn::ForEach blocks. - def patched_func(self): - if self.condition: - if not isinstance(self.condition, str): - raise InvalidDocumentException( - [InvalidTemplateException("Every Condition member must be a string.")] - ) - return SamResourceType.has_value(self.type) + This method returns the dynamic artifact properties that were detected + during template processing. These properties use loop variables in their + values (e.g., CodeUri: ./services/${Name}) and need to be handled via + Mappings transformation during sam package. - SamResource.valid = patched_func + Returns + ------- + List[DynamicArtifactProperty] + List of dynamic artifact property locations + """ + return self._dynamic_artifact_properties @staticmethod - def _check_using_language_extension(template: Dict) -> bool: + def _check_using_language_extension(template: Optional[Dict]) -> bool: """ - Check if language extensions are set in the template's Transform - :param template: template to check - :return: True if language extensions are set in the template, False otherwise + Check if language extensions are set in the template's Transform. + + This is a backward-compatible alias. The canonical implementation + is in samcli.lib.cfn_language_extensions.sam_integration.check_using_language_extension. + + Parameters + ---------- + template : dict + The template to check + + Returns + ------- + bool + True if language extensions are set in the template, False otherwise """ - transform = template.get("Transform") - if transform: - if isinstance(transform, str) and transform.startswith("AWS::LanguageExtensions"): - return True - if isinstance(transform, list): - for transform_instance in transform: - if not isinstance(transform_instance, str): - continue - if transform_instance.startswith("AWS::LanguageExtensions"): - return True - return False + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + return check_using_language_extension(template) class _SamParserReimplemented: diff --git a/samcli/lib/translate/sam_template_validator.py b/samcli/lib/translate/sam_template_validator.py index 5c2a9ed33da..cb379db1575 100644 --- a/samcli/lib/translate/sam_template_validator.py +++ b/samcli/lib/translate/sam_template_validator.py @@ -13,6 +13,8 @@ from samtranslator.translator.translator import Translator from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions +from samcli.lib.cfn_language_extensions.utils import is_foreach_key from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.resources import AWS_SERVERLESS_FUNCTION from samcli.yamlhelper import yaml_dump @@ -28,6 +30,7 @@ def __init__( profile: Optional[str] = None, region: Optional[str] = None, parameter_overrides: Optional[dict] = None, + template_path: Optional[str] = None, ): """ Construct a SamTemplateValidator @@ -54,12 +57,16 @@ def __init__( Optional AWS region name parameter_overrides: Optional[dict] Template parameter overrides + template_path: Optional[str] + Optional path to the template file on disk. When provided, enables + caching of language extension expansion results. """ self.sam_template = sam_template self.managed_policy_loader = managed_policy_loader self.sam_parser = parser.Parser() self.boto3_session = Session(profile_name=profile, region_name=region) self.parameter_overrides = parameter_overrides or {} + self.template_path = template_path def get_translated_template_if_valid(self): """ @@ -79,6 +86,13 @@ def get_translated_template_if_valid(self): boto_session=self.boto3_session, ) + # Process language extensions before validation if AWS::LanguageExtensions transform is present + result = expand_language_extensions( + self.sam_template, parameter_values=self.parameter_overrides, template_path=self.template_path + ) + if result.had_language_extensions: + self.sam_template = result.expanded_template + self._replace_local_codeuri() self._replace_local_image() @@ -124,13 +138,17 @@ def _replace_local_codeuri(self): if all( [ _properties.get("Properties", {}).get("PackageType", ZIP) == ZIP - for _, _properties in all_resources.items() + for resource_key, _properties in all_resources.items() + if not is_foreach_key(resource_key) and isinstance(_properties, dict) ] + [_properties.get("PackageType", ZIP) == ZIP for _, _properties in global_settings.items()] ): SamTemplateValidator._update_to_s3_uri("CodeUri", properties) - for _, resource in all_resources.items(): + for resource_key, resource in all_resources.items(): + if is_foreach_key(resource_key) or not isinstance(resource, dict): + continue + resource_type = resource.get("Type") resource_dict = resource.get("Properties", {}) @@ -158,7 +176,10 @@ def _replace_local_image(self): This ensures sam validate works without having to package the app or use ImageUri. """ resources = self.sam_template.get("Resources", {}) - for _, resource in resources.items(): + for resource_key, resource in resources.items(): + if is_foreach_key(resource_key) or not isinstance(resource, dict): + continue + resource_type = resource.get("Type") properties = resource.get("Properties", {}) diff --git a/samcli/lib/warnings/sam_cli_warning.py b/samcli/lib/warnings/sam_cli_warning.py index 6e5aaf46fab..fc782ced570 100644 --- a/samcli/lib/warnings/sam_cli_warning.py +++ b/samcli/lib/warnings/sam_cli_warning.py @@ -5,6 +5,8 @@ import logging from typing import Dict +from samcli.lib.cfn_language_extensions.utils import iter_resources + LOG = logging.getLogger(__name__) @@ -77,7 +79,7 @@ def check(self, template_dict): """ functions = [ resource - for (_, resource) in template_dict.get("Resources", {}).items() + for (_, resource) in iter_resources(template_dict) if resource.get("Type", "") == "AWS::Serverless::Function" ] deployment_features_enabled_count = sum( @@ -109,7 +111,7 @@ def check(self, template_dict): """ functions = [ resource - for (_, resource) in template_dict.get("Resources", {}).items() + for (_, resource) in iter_resources(template_dict) if resource.get("Type", "") == "AWS::Serverless::Function" ] for function in functions: diff --git a/tests/unit/commands/_utils/test_template.py b/tests/unit/commands/_utils/test_template.py index f739befbccd..4c809eef37c 100644 --- a/tests/unit/commands/_utils/test_template.py +++ b/tests/unit/commands/_utils/test_template.py @@ -466,6 +466,232 @@ def _assert_templates_are_equal(self, actual, expected, tested_type, property_na self.assertEqual(actual, expected) +class Test_update_sam_mappings_relative_paths(TestCase): + """Tests for _update_sam_mappings_relative_paths which adjusts paths in SAM-generated Mappings.""" + + def test_updates_relative_paths_in_sam_mappings(self): + """SAM-prefixed Mapping values that are relative paths should be adjusted.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + mappings = { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": os.path.join(".aws-sam", "build", "AlphaFunction")}, + "Beta": {"CodeUri": os.path.join(".aws-sam", "build", "BetaFunction")}, + } + } + + with tempfile.TemporaryDirectory() as tmpdir: + original_root = tmpdir + new_root = os.path.join(tmpdir, ".aws-sam", "build") + os.makedirs(new_root, exist_ok=True) + + _update_sam_mappings_relative_paths(mappings, original_root, new_root) + + # After adjustment, paths should be relative to new_root (.aws-sam/build/) + # So .aws-sam/build/AlphaFunction relative to .aws-sam/build/ = AlphaFunction + self.assertEqual(mappings["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], "AlphaFunction") + self.assertEqual(mappings["SAMCodeUriFunctions"]["Beta"]["CodeUri"], "BetaFunction") + + def test_skips_non_sam_mappings(self): + """Mappings without the SAM prefix should not be modified.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + mappings = { + "UserDefinedMapping": { + "us-east-1": {"AMI": "ami-12345"}, + } + } + original = copy.deepcopy(mappings) + + _update_sam_mappings_relative_paths(mappings, "/original", "/new") + + self.assertEqual(mappings, original) + + def test_skips_s3_uris_in_mappings(self): + """S3 URIs in Mappings should not be modified.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + mappings = { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": "s3://bucket/key/alpha.zip"}, + "Beta": {"CodeUri": "s3://bucket/key/beta.zip"}, + } + } + original = copy.deepcopy(mappings) + + _update_sam_mappings_relative_paths(mappings, "/original", "/new") + + self.assertEqual(mappings, original) + + def test_handles_empty_mappings(self): + """Empty or non-dict Mappings should not cause errors.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + _update_sam_mappings_relative_paths({}, "/original", "/new") + _update_sam_mappings_relative_paths(None, "/original", "/new") + + def test_move_template_adjusts_sam_mappings(self): + """End-to-end: move_template should adjust SAM Mapping paths correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + src_dir = tmpdir + build_dir = os.path.join(tmpdir, ".aws-sam", "build") + os.makedirs(build_dir, exist_ok=True) + + template_dict = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "main.handler", + "CodeUri": {"Fn::FindInMap": ["SAMCodeUriFunctions", "${Name}", "CodeUri"]}, + }, + } + }, + ] + }, + "Mappings": { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": os.path.join(".aws-sam", "build", "AlphaFunction")}, + "Beta": {"CodeUri": os.path.join(".aws-sam", "build", "BetaFunction")}, + } + }, + } + + src_template = os.path.join(src_dir, "template.yaml") + dest_template = os.path.join(build_dir, "template.yaml") + + move_template(src_template, dest_template, template_dict) + + with open(dest_template, "r") as f: + result = yaml.safe_load(f.read()) + + mappings = result.get("Mappings", {}) + self.assertEqual(mappings["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], "AlphaFunction") + self.assertEqual(mappings["SAMCodeUriFunctions"]["Beta"]["CodeUri"], "BetaFunction") + + def test_skips_docker_image_uris_in_imageuri_mappings(self): + """Docker image references in ImageUri Mappings should not be rewritten with relative paths.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + mappings = { + "SAMImageUriFunctions": { + "alpha": {"ImageUri": "emulation-python3.9-alpha:latest"}, + "beta": {"ImageUri": "emulation-python3.9-beta:latest"}, + } + } + original = copy.deepcopy(mappings) + + with tempfile.TemporaryDirectory() as tmpdir: + original_root = tmpdir + new_root = os.path.join(tmpdir, ".aws-sam", "build") + os.makedirs(new_root, exist_ok=True) + + _update_sam_mappings_relative_paths(mappings, original_root, new_root) + + # Docker image references should remain unchanged + self.assertEqual(mappings, original) + + def test_updates_imageuri_when_pointing_to_local_archive(self): + """ImageUri values that point to actual local files (e.g., .tar.gz archives) should be updated.""" + from samcli.commands._utils.template import _update_sam_mappings_relative_paths + + with tempfile.TemporaryDirectory() as tmpdir: + original_root = tmpdir + new_root = os.path.join(tmpdir, ".aws-sam", "build") + os.makedirs(new_root, exist_ok=True) + + # Create a fake image archive at the resolved relative path from CWD + # _resolve_relative_to computes a path relative to new_root, and + # pathlib.Path(updated_path).is_file() checks relative to CWD. + # We need the file to exist at the CWD-relative resolved path. + resolved_relative = os.path.relpath( + os.path.join(original_root, "my-image.tar.gz"), + new_root, + ) + # Create the archive at the CWD-relative resolved path + resolved_abs = os.path.join(os.getcwd(), resolved_relative) + os.makedirs(os.path.dirname(resolved_abs), exist_ok=True) + with open(resolved_abs, "w") as f: + f.write("fake archive") + + try: + mappings = { + "SAMImageUriFunctions": { + "alpha": {"ImageUri": "my-image.tar.gz"}, + } + } + + _update_sam_mappings_relative_paths(mappings, original_root, new_root) + + # The path should be updated since it resolves to a real local file + updated_uri = mappings["SAMImageUriFunctions"]["alpha"]["ImageUri"] + self.assertEqual(updated_uri, resolved_relative) + finally: + # Clean up the file we created relative to CWD + if os.path.exists(resolved_abs): + os.remove(resolved_abs) + + def test_move_template_preserves_docker_imageuri_in_sam_mappings(self): + """End-to-end: move_template should not rewrite Docker image references in SAM ImageUri Mappings.""" + with tempfile.TemporaryDirectory() as tmpdir: + src_dir = tmpdir + build_dir = os.path.join(tmpdir, ".aws-sam", "build") + os.makedirs(build_dir, exist_ok=True) + + template_dict = { + "Resources": { + "Fn::ForEach::Functions": [ + "FunctionName", + ["alpha", "beta"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${FunctionName}Function"}, + "PackageType": "Image", + "ImageUri": { + "Fn::FindInMap": [ + "SAMImageUriFunctions", + {"Ref": "FunctionName"}, + "ImageUri", + ] + }, + }, + } + }, + ] + }, + "Mappings": { + "SAMImageUriFunctions": { + "alpha": {"ImageUri": "emulation-python3.9-alpha:latest"}, + "beta": {"ImageUri": "emulation-python3.9-beta:latest"}, + } + }, + } + + src_template = os.path.join(src_dir, "template.yaml") + dest_template = os.path.join(build_dir, "template.yaml") + + move_template(src_template, dest_template, template_dict) + + with open(dest_template, "r") as f: + result = yaml.safe_load(f.read()) + + mappings = result.get("Mappings", {}) + self.assertEqual( + mappings["SAMImageUriFunctions"]["alpha"]["ImageUri"], + "emulation-python3.9-alpha:latest", + ) + self.assertEqual( + mappings["SAMImageUriFunctions"]["beta"]["ImageUri"], + "emulation-python3.9-beta:latest", + ) + + class Test_resolve_relative_to(TestCase): def setUp(self): self.scratchdir = os.path.split(tempfile.mkdtemp(dir=os.curdir))[-1] @@ -624,6 +850,106 @@ def test_template_get_artifacts_format_none_other_resources_present(self, mock_g } self.assertEqual(get_template_artifacts_format(MagicMock()), []) + @patch("samcli.commands._utils.template.get_template_data") + def test_template_get_artifacts_format_with_foreach(self, mock_get_template_data): + """Test that artifacts are detected inside Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": AWS_SERVERLESS_FUNCTION, + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + } + }, + ] + } + } + self.assertEqual(get_template_artifacts_format(MagicMock()), [ZIP]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_template_get_artifacts_format_with_nested_foreach(self, mock_get_template_data): + """Test that artifacts are detected inside nested Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Outer": [ + "OuterVar", + ["A", "B"], + { + "Fn::ForEach::Inner": [ + "InnerVar", + ["X", "Y"], + { + "${OuterVar}${InnerVar}Function": { + "Type": AWS_SERVERLESS_FUNCTION, + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + } + }, + ] + }, + ] + } + } + self.assertEqual(get_template_artifacts_format(MagicMock()), [ZIP]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_template_get_artifacts_format_with_deeply_nested_foreach(self, mock_get_template_data): + """Test that artifacts are detected inside deeply nested Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Level1": [ + "L1", + ["A"], + { + "Fn::ForEach::Level2": [ + "L2", + ["B"], + { + "Fn::ForEach::Level3": [ + "L3", + ["C"], + { + "${L1}${L2}${L3}Function": { + "Type": AWS_SERVERLESS_FUNCTION, + "Properties": {"ImageUri": "myimage", "PackageType": IMAGE}, + } + }, + ] + }, + ] + }, + ] + } + } + self.assertEqual(get_template_artifacts_format(MagicMock()), [IMAGE]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_template_get_artifacts_format_mixed_foreach_and_regular(self, mock_get_template_data): + """Test that artifacts are detected from both Fn::ForEach and regular resources.""" + mock_get_template_data.return_value = { + "Resources": { + "RegularFunction": { + "Type": AWS_SERVERLESS_FUNCTION, + "Properties": {"ImageUri": "myimage", "PackageType": IMAGE}, + }, + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": AWS_SERVERLESS_FUNCTION, + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + } + }, + ], + } + } + result = get_template_artifacts_format(MagicMock()) + self.assertIn(IMAGE, result) + self.assertIn(ZIP, result) + class Test_get_template_function_resouce_ids(TestCase): @patch("samcli.commands._utils.template.get_template_data") @@ -635,3 +961,146 @@ def test_get_template_function_resouce_ids(self, mock_get_template_data): } } self.assertEqual(get_template_function_resource_ids(MagicMock(), IMAGE), ["HelloWorldFunction1"]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_with_foreach(self, mock_get_template_data): + """Test that function IDs are detected inside Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + } + }, + ] + } + } + result = get_template_function_resource_ids(MagicMock(), ZIP) + # Should return the ForEach key as a placeholder + self.assertEqual(result, ["Fn::ForEach::Functions"]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_with_nested_foreach(self, mock_get_template_data): + """Test that function IDs are detected inside nested Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Outer": [ + "OuterVar", + ["A", "B"], + { + "Fn::ForEach::Inner": [ + "InnerVar", + ["X", "Y"], + { + "${OuterVar}${InnerVar}Function": { + "Type": "AWS::Lambda::Function", + "Properties": {"PackageType": IMAGE}, + } + }, + ] + }, + ] + } + } + result = get_template_function_resource_ids(MagicMock(), IMAGE) + # Should return the outer ForEach key as a placeholder + self.assertEqual(result, ["Fn::ForEach::Outer"]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_with_deeply_nested_foreach(self, mock_get_template_data): + """Test that function IDs are detected inside deeply nested Fn::ForEach blocks.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Level1": [ + "L1", + ["A"], + { + "Fn::ForEach::Level2": [ + "L2", + ["B"], + { + "Fn::ForEach::Level3": [ + "L3", + ["C"], + { + "${L1}${L2}${L3}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + } + }, + ] + }, + ] + }, + ] + } + } + result = get_template_function_resource_ids(MagicMock(), ZIP) + self.assertEqual(result, ["Fn::ForEach::Level1"]) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_mixed_foreach_and_regular(self, mock_get_template_data): + """Test that function IDs are detected from both Fn::ForEach and regular resources.""" + mock_get_template_data.return_value = { + "Resources": { + "RegularFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src", "PackageType": ZIP}, + }, + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Lambda::Function", + "Properties": {"PackageType": ZIP}, + } + }, + ], + } + } + result = get_template_function_resource_ids(MagicMock(), ZIP) + self.assertIn("RegularFunction", result) + self.assertIn("Fn::ForEach::Functions", result) + self.assertEqual(len(result), 2) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_foreach_no_matching_artifact(self, mock_get_template_data): + """Test that ForEach blocks with non-matching artifact types are not included.""" + mock_get_template_data.return_value = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "myimage", "PackageType": IMAGE}, + } + }, + ] + } + } + # Looking for ZIP but ForEach contains IMAGE functions + result = get_template_function_resource_ids(MagicMock(), ZIP) + self.assertEqual(result, []) + + @patch("samcli.commands._utils.template.get_template_data") + def test_get_template_function_resource_ids_foreach_invalid_structure(self, mock_get_template_data): + """Test that invalid Fn::ForEach structures are handled gracefully.""" + mock_get_template_data.return_value = { + "Resources": { + # Invalid: not a list + "Fn::ForEach::Invalid1": "not a list", + # Invalid: list too short + "Fn::ForEach::Invalid2": ["only", "two"], + # Invalid: output template not a dict + "Fn::ForEach::Invalid3": ["var", ["a", "b"], "not a dict"], + } + } + result = get_template_function_resource_ids(MagicMock(), ZIP) + self.assertEqual(result, []) diff --git a/tests/unit/commands/_utils/test_template_language_extensions.py b/tests/unit/commands/_utils/test_template_language_extensions.py new file mode 100644 index 00000000000..1e779d21cf5 --- /dev/null +++ b/tests/unit/commands/_utils/test_template_language_extensions.py @@ -0,0 +1,329 @@ +""" +Tests for template.py language extensions support. + +Covers _update_foreach_relative_paths, _update_sam_mappings_relative_paths, +_get_artifacts_from_foreach, and _get_function_ids_from_foreach. +""" + +import os +import tempfile +from unittest import TestCase +from unittest.mock import patch + +from samcli.commands._utils.template import ( + _update_foreach_relative_paths, + _update_sam_mappings_relative_paths, + _get_artifacts_from_foreach, + _get_function_ids_from_foreach, +) +from samcli.lib.utils.packagetype import ZIP, IMAGE + + +class TestUpdateForeachRelativePaths(TestCase): + """Tests for _update_foreach_relative_paths.""" + + def test_invalid_foreach_not_list(self): + # Should not raise + _update_foreach_relative_paths("not a list", "/old", "/new") + + def test_invalid_foreach_too_short(self): + _update_foreach_relative_paths(["only", "two"], "/old", "/new") + + def test_non_dict_output_template(self): + _update_foreach_relative_paths(["Name", ["A"], "not a dict"], "/old", "/new") + + def test_updates_serverless_function_codeuri(self): + with tempfile.TemporaryDirectory() as tmpdir: + old_root = tmpdir + new_root = os.path.join(tmpdir, "build") + os.makedirs(new_root, exist_ok=True) + # Create a source directory + src_dir = os.path.join(old_root, "src") + os.makedirs(src_dir, exist_ok=True) + + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "src"}, + } + }, + ] + _update_foreach_relative_paths(foreach_value, old_root, new_root) + # CodeUri should be updated to relative path from new_root + updated = foreach_value[2]["${Name}Func"]["Properties"]["CodeUri"] + self.assertIsNotNone(updated) + + def test_skips_non_dict_resource_def(self): + foreach_value = [ + "Name", + ["A"], + {"${Name}Func": "not a dict"}, + ] + # Should not raise + _update_foreach_relative_paths(foreach_value, "/old", "/new") + + def test_skips_non_packageable_resource_type(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Topic": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": "${Name}"}, + } + }, + ] + _update_foreach_relative_paths(foreach_value, "/old", "/new") + # Properties should be unchanged + self.assertEqual(foreach_value[2]["${Name}Topic"]["Properties"]["TopicName"], "${Name}") + + def test_handles_nested_foreach(self): + foreach_value = [ + "Outer", + ["A"], + { + "Fn::ForEach::Inner": [ + "Inner", + ["X"], + { + "${Inner}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "src"}, + } + }, + ] + }, + ] + # Should not raise - recursion should work + _update_foreach_relative_paths(foreach_value, "/old", "/new") + + +class TestUpdateSamMappingsRelativePaths(TestCase): + """Tests for _update_sam_mappings_relative_paths.""" + + def test_non_dict_mappings(self): + # Should not raise + _update_sam_mappings_relative_paths("not a dict", "/old", "/new") + + def test_skips_non_sam_mappings(self): + mappings = { + "RegionMap": { + "us-east-1": {"AMI": "ami-12345"}, + } + } + _update_sam_mappings_relative_paths(mappings, "/old", "/new") + # Should be unchanged + self.assertEqual(mappings["RegionMap"]["us-east-1"]["AMI"], "ami-12345") + + def test_skips_non_dict_mapping_entries(self): + mappings = {"SAMCodeUriFunctions": "not a dict"} + _update_sam_mappings_relative_paths(mappings, "/old", "/new") + + def test_skips_non_dict_value_dict(self): + mappings = {"SAMCodeUriFunctions": {"Alpha": "not a dict"}} + _update_sam_mappings_relative_paths(mappings, "/old", "/new") + + def test_updates_sam_mapping_paths(self): + with tempfile.TemporaryDirectory() as tmpdir: + old_root = tmpdir + new_root = os.path.join(tmpdir, "build") + os.makedirs(new_root, exist_ok=True) + src_dir = os.path.join(old_root, "services", "users") + os.makedirs(src_dir, exist_ok=True) + + mappings = { + "SAMCodeUriFunctions": { + "Users": {"CodeUri": os.path.join("services", "users")}, + } + } + _update_sam_mappings_relative_paths(mappings, old_root, new_root) + updated = mappings["SAMCodeUriFunctions"]["Users"]["CodeUri"] + self.assertIsNotNone(updated) + + +class TestGetArtifactsFromForeach(TestCase): + """Tests for _get_artifacts_from_foreach.""" + + def _get_packageable_resources(self): + from samcli.commands._utils.template import get_packageable_resource_paths + + return get_packageable_resource_paths() + + def test_invalid_foreach_not_list(self): + result = _get_artifacts_from_foreach("not a list", self._get_packageable_resources()) + self.assertEqual(result, []) + + def test_invalid_foreach_too_short(self): + result = _get_artifacts_from_foreach(["only"], self._get_packageable_resources()) + self.assertEqual(result, []) + + def test_non_dict_output_template(self): + result = _get_artifacts_from_foreach(["Name", ["A"], "not a dict"], self._get_packageable_resources()) + self.assertEqual(result, []) + + def test_serverless_function_zip(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src/${Name}", "Runtime": "python3.13"}, + } + }, + ] + result = _get_artifacts_from_foreach(foreach_value, self._get_packageable_resources()) + self.assertEqual(result, [ZIP]) + + def test_serverless_function_image(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src", "PackageType": IMAGE}, + } + }, + ] + result = _get_artifacts_from_foreach(foreach_value, self._get_packageable_resources()) + self.assertEqual(result, [IMAGE]) + + def test_non_packageable_resource(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Topic": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": "${Name}"}, + } + }, + ] + result = _get_artifacts_from_foreach(foreach_value, self._get_packageable_resources()) + self.assertEqual(result, []) + + def test_non_dict_resource_def_skipped(self): + foreach_value = ["Name", ["A"], {"${Name}Func": "not a dict"}] + result = _get_artifacts_from_foreach(foreach_value, self._get_packageable_resources()) + self.assertEqual(result, []) + + def test_nested_foreach(self): + foreach_value = [ + "Outer", + ["A"], + { + "Fn::ForEach::Inner": [ + "Inner", + ["X"], + { + "${Inner}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + ] + }, + ] + result = _get_artifacts_from_foreach(foreach_value, self._get_packageable_resources()) + self.assertEqual(result, [ZIP]) + + +class TestGetFunctionIdsFromForeach(TestCase): + """Tests for _get_function_ids_from_foreach.""" + + def test_invalid_foreach_not_list(self): + result = _get_function_ids_from_foreach("not a list", ZIP) + self.assertEqual(result, []) + + def test_invalid_foreach_too_short(self): + result = _get_function_ids_from_foreach(["only", "two"], ZIP) + self.assertEqual(result, []) + + def test_non_dict_output_template(self): + result = _get_function_ids_from_foreach(["Name", ["A"], "not a dict"], ZIP) + self.assertEqual(result, []) + + def test_zip_function_found(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + ] + result = _get_function_ids_from_foreach(foreach_value, ZIP) + self.assertEqual(result, ["${Name}Func"]) + + def test_image_function_found(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"PackageType": IMAGE}, + } + }, + ] + result = _get_function_ids_from_foreach(foreach_value, IMAGE) + self.assertEqual(result, ["${Name}Func"]) + + def test_zip_function_not_matched_for_image_artifact(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + ] + result = _get_function_ids_from_foreach(foreach_value, IMAGE) + self.assertEqual(result, []) + + def test_non_function_resource_skipped(self): + foreach_value = [ + "Name", + ["A"], + { + "${Name}Api": { + "Type": "AWS::Serverless::Api", + "Properties": {"DefinitionUri": "./api.yaml"}, + } + }, + ] + result = _get_function_ids_from_foreach(foreach_value, ZIP) + self.assertEqual(result, []) + + def test_non_dict_resource_def_skipped(self): + foreach_value = ["Name", ["A"], {"${Name}Func": "not a dict"}] + result = _get_function_ids_from_foreach(foreach_value, ZIP) + self.assertEqual(result, []) + + def test_nested_foreach(self): + foreach_value = [ + "Outer", + ["A"], + { + "Fn::ForEach::Inner": [ + "Inner", + ["X"], + { + "${Inner}Func": { + "Type": "AWS::Lambda::Function", + "Properties": {"Code": "./src"}, + } + }, + ] + }, + ] + result = _get_function_ids_from_foreach(foreach_value, ZIP) + self.assertEqual(result, ["${Inner}Func"]) diff --git a/tests/unit/commands/local/lib/test_sam_base_provider.py b/tests/unit/commands/local/lib/test_sam_base_provider.py index af233a1bf50..d6400a0ac7e 100644 --- a/tests/unit/commands/local/lib/test_sam_base_provider.py +++ b/tests/unit/commands/local/lib/test_sam_base_provider.py @@ -22,5 +22,7 @@ def test_must_run_translator_plugins( SamBaseProvider.get_template(template, overrides) called_parameter_values = IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES.copy() called_parameter_values.update(overrides) - SamTranslatorWrapperMock.assert_called_once_with(template, parameter_values=called_parameter_values) + SamTranslatorWrapperMock.assert_called_once_with( + template, parameter_values=called_parameter_values, language_extension_result=None + ) translator_instance.run_plugins.assert_called_once() diff --git a/tests/unit/commands/validate/lib/test_sam_template_validator_language_extensions.py b/tests/unit/commands/validate/lib/test_sam_template_validator_language_extensions.py new file mode 100644 index 00000000000..d9d482bdd3a --- /dev/null +++ b/tests/unit/commands/validate/lib/test_sam_template_validator_language_extensions.py @@ -0,0 +1,244 @@ +""" +Tests for SamTemplateValidator language extensions processing. + +Covers expand_language_extensions() integration in get_translated_template_if_valid(), +_replace_local_codeuri with ForEach, and _replace_local_image with ForEach. +""" + +from unittest import TestCase +from unittest.mock import Mock, patch + +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult +from samcli.lib.translate.sam_template_validator import SamTemplateValidator + + +class TestExpandLanguageExtensionsIntegration(TestCase): + """Tests for expand_language_extensions() integration in SamTemplateValidator.""" + + def _make_validator(self, template, parameter_overrides=None): + managed_policy_mock = Mock() + return SamTemplateValidator(template, managed_policy_mock, parameter_overrides=parameter_overrides) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_no_language_extensions_template_unchanged(self, mock_expand): + """When template has no language extensions, expand returns had_language_extensions=False.""" + template = {"Resources": {"MyFunc": {"Type": "AWS::Serverless::Function"}}} + mock_expand.return_value = LanguageExtensionResult( + expanded_template=template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + validator = self._make_validator(template) + # Call expand via the validator's flow (we test the integration, not the full translate) + result = mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + self.assertFalse(result.had_language_extensions) + # Template should remain unchanged + self.assertIn("MyFunc", validator.sam_template["Resources"]) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_language_extensions_template_expanded(self, mock_expand): + """When template has language extensions, expand returns expanded template.""" + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A", "B"], + {"${Name}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + expanded_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AResource": {"Type": "AWS::SNS::Topic"}, + "BResource": {"Type": "AWS::SNS::Topic"}, + }, + } + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded_template, + original_template=original_template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + validator = self._make_validator(original_template) + + # Simulate what get_translated_template_if_valid does + result = mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + if result.had_language_extensions: + validator.sam_template = result.expanded_template + + mock_expand.assert_called_once_with(original_template, parameter_values={}) + self.assertIn("AResource", validator.sam_template["Resources"]) + self.assertIn("BResource", validator.sam_template["Resources"]) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_language_extensions_invalid_template_raises(self, mock_expand): + """When expand_language_extensions raises InvalidSamDocumentException, it propagates.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": {"Fn::ForEach::Loop": "invalid"}, + } + mock_expand.side_effect = InvalidSamDocumentException("bad template") + validator = self._make_validator(template) + + with self.assertRaises(InvalidSamDocumentException): + mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_language_extensions_non_langext_exception_reraises(self, mock_expand): + """Non-language-extension exceptions propagate unchanged.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": {"Fn::ForEach::Loop": ["Name", ["A"], {}]}, + } + mock_expand.side_effect = RuntimeError("unexpected error") + validator = self._make_validator(template) + + with self.assertRaises(RuntimeError): + mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_non_language_extensions_transform_not_expanded(self, mock_expand): + """When template has only SAM transform, expand returns had_language_extensions=False.""" + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": {"MyFunc": {"Type": "AWS::Serverless::Function"}}, + } + mock_expand.return_value = LanguageExtensionResult( + expanded_template=template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + validator = self._make_validator(template) + + result = mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + self.assertFalse(result.had_language_extensions) + self.assertIn("MyFunc", validator.sam_template["Resources"]) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_parameter_overrides_passed_to_expand(self, mock_expand): + """Parameter overrides are passed to expand_language_extensions.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + {"Ref": "Names"}, + {"${Name}Resource": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + expanded_template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AlphaResource": {"Type": "AWS::SNS::Topic"}, + }, + } + param_overrides = {"Names": "Alpha"} + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded_template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + validator = self._make_validator(template, parameter_overrides=param_overrides) + + result = mock_expand(validator.sam_template, parameter_values=validator.parameter_overrides) + if result.had_language_extensions: + validator.sam_template = result.expanded_template + + mock_expand.assert_called_once_with(template, parameter_values=param_overrides) + + def test_process_language_extensions_method_removed(self): + """Verify _process_language_extensions() method no longer exists on SamTemplateValidator.""" + self.assertFalse(hasattr(SamTemplateValidator, "_process_language_extensions")) + + +class TestReplacLocalCodeuriWithForEach(TestCase): + """Tests for _replace_local_codeuri with Fn::ForEach blocks in Resources.""" + + def _make_validator(self, template): + managed_policy_mock = Mock() + return SamTemplateValidator(template, managed_policy_mock) + + def test_foreach_entries_skipped_in_replace_local_codeuri(self): + """Fn::ForEach entries (which are lists, not dicts) should be skipped.""" + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./${Name}", "Handler": "main.handler"}, + } + }, + ], + "RegularFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler", "CodeUri": "./src", "Runtime": "python3.13"}, + }, + }, + } + validator = self._make_validator(template) + # Should not raise - ForEach entries are lists and should be skipped + validator._replace_local_codeuri() + # Regular function should have its CodeUri replaced + self.assertEqual( + validator.sam_template["Resources"]["RegularFunction"]["Properties"]["CodeUri"], + "s3://bucket/value", + ) + + def test_foreach_entries_skipped_in_replace_local_image(self): + """Fn::ForEach entries should be skipped in _replace_local_image.""" + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha"], + {"${Name}Function": {"Type": "AWS::Serverless::Function", "Properties": {"PackageType": "Image"}}}, + ], + "RegularFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"PackageType": "Image"}, + "Metadata": {"Dockerfile": "Dockerfile"}, + }, + }, + } + validator = self._make_validator(template) + validator._replace_local_image() + # Regular function should get ImageUri added + self.assertEqual( + validator.sam_template["Resources"]["RegularFunction"]["Properties"]["ImageUri"], + "111111111111.dkr.ecr.region.amazonaws.com/repository", + ) + + def test_foreach_in_globals_codeuri_check(self): + """Globals CodeUri replacement should handle ForEach entries in Resources.""" + template = { + "Globals": {"Function": {"CodeUri": "globalcodeuri"}}, + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "main.handler"}, + } + }, + ], + "RegularFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler", "Runtime": "python3.13"}, + }, + }, + } + validator = self._make_validator(template) + # Should not raise when iterating resources that include ForEach (list) entries + validator._replace_local_codeuri() diff --git a/tests/unit/lib/cfn_language_extensions/test_phase_separation.py b/tests/unit/lib/cfn_language_extensions/test_phase_separation.py new file mode 100644 index 00000000000..636708fe41d --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_phase_separation.py @@ -0,0 +1,904 @@ +""" +Unit tests and property tests for Phase Separation (Tasks 35.1-35.5). + +This module covers: +- Task 35.1: LanguageExtensionResult dataclass tests +- Task 35.2: expand_language_extensions() unit tests +- Task 35.3: run_plugins() no longer calls Phase 1 tests +- Task 35.4: All callers use expand_language_extensions() tests +- Task 35.5: Property tests for phase separation correctness (Properties 10, 11) + +Requirements tested: + - 23.1, 23.2: LanguageExtensionResult dataclass + - 23.3, 23.4, 23.5: expand_language_extensions() behavior + - 23.6: run_plugins() Phase 2 only + - 23.7: All callers use expand_language_extensions() + - 20.1, 20.7, 20.9: Phase separation correctness properties +""" + +import copy +import dataclasses +from typing import Any, Dict, List +from unittest import TestCase +from unittest.mock import MagicMock, patch, call + +import pytest + +from samcli.lib.cfn_language_extensions.sam_integration import ( + LanguageExtensionResult, + expand_language_extensions, + clear_expansion_cache, + check_using_language_extension, +) +from samcli.lib.samlib.wrapper import DynamicArtifactProperty, SamTranslatorWrapper + + +# ============================================================================= +# Task 35.1: Unit Tests for LanguageExtensionResult Dataclass +# ============================================================================= + + +class TestLanguageExtensionResultDataclass(TestCase): + """ + Unit tests for the LanguageExtensionResult dataclass. + + Validates: Requirements 23.1, 23.2 + """ + + def test_creation_with_all_fields(self): + """Test LanguageExtensionResult can be created with all fields.""" + expanded = {"Resources": {"AFunc": {"Type": "AWS::Lambda::Function"}}} + original = { + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A"], + {"${Name}Func": {"Type": "AWS::Lambda::Function"}}, + ] + } + } + dynamic_props = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Loop", + loop_name="Loop", + loop_variable="Name", + collection=["A"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + ] + + result = LanguageExtensionResult( + expanded_template=expanded, + original_template=original, + dynamic_artifact_properties=dynamic_props, + had_language_extensions=True, + ) + + self.assertEqual(result.expanded_template, expanded) + self.assertEqual(result.original_template, original) + self.assertEqual(result.dynamic_artifact_properties, dynamic_props) + self.assertTrue(result.had_language_extensions) + + def test_creation_with_defaults(self): + """Test LanguageExtensionResult uses correct defaults for optional fields.""" + template = {"Resources": {"MyFunc": {"Type": "AWS::Lambda::Function"}}} + + result = LanguageExtensionResult( + expanded_template=template, + original_template=template, + ) + + self.assertEqual(result.dynamic_artifact_properties, []) + self.assertFalse(result.had_language_extensions) + + def test_frozen_immutable_behavior(self): + """Test that LanguageExtensionResult is frozen (immutable).""" + template = {"Resources": {}} + result = LanguageExtensionResult( + expanded_template=template, + original_template=template, + had_language_extensions=True, + ) + + with self.assertRaises(dataclasses.FrozenInstanceError): + result.expanded_template = {"Resources": {"New": {}}} + + with self.assertRaises(dataclasses.FrozenInstanceError): + result.original_template = {"Resources": {"New": {}}} + + with self.assertRaises(dataclasses.FrozenInstanceError): + result.had_language_extensions = False + + with self.assertRaises(dataclasses.FrozenInstanceError): + result.dynamic_artifact_properties = [] + + def test_had_language_extensions_false_case(self): + """Test LanguageExtensionResult with had_language_extensions=False.""" + template = {"Resources": {"MyFunc": {"Type": "AWS::Lambda::Function"}}} + + result = LanguageExtensionResult( + expanded_template=template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + + self.assertFalse(result.had_language_extensions) + # When no language extensions, expanded and original should be the same reference + self.assertIs(result.expanded_template, result.original_template) + + def test_is_dataclass(self): + """Test that LanguageExtensionResult is a proper dataclass.""" + self.assertTrue(dataclasses.is_dataclass(LanguageExtensionResult)) + + def test_is_frozen(self): + """Test that LanguageExtensionResult is frozen.""" + fields = dataclasses.fields(LanguageExtensionResult) + # frozen=True means the dataclass is immutable + self.assertTrue(LanguageExtensionResult.__dataclass_params__.frozen) + + +# ============================================================================= +# Task 35.2: Unit Tests for expand_language_extensions() +# ============================================================================= + + +class TestExpandLanguageExtensions(TestCase): + """ + Unit tests for the expand_language_extensions() function. + + Validates: Requirements 23.1, 23.2, 23.3, 23.4, 23.5 + """ + + def setUp(self): + """Clear the expansion cache before each test.""" + clear_expansion_cache() + + def test_returns_result_with_expanded_template_for_language_extensions(self): + """expand_language_extensions() returns LanguageExtensionResult with expanded template.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["Alpha", "Beta"], + {"${Name}Topic": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + + result = expand_language_extensions(template) + + self.assertIsInstance(result, LanguageExtensionResult) + self.assertTrue(result.had_language_extensions) + # Expanded template should have individual resources + self.assertIn("AlphaTopic", result.expanded_template["Resources"]) + self.assertIn("BetaTopic", result.expanded_template["Resources"]) + self.assertNotIn("Fn::ForEach::Loop", result.expanded_template["Resources"]) + + def test_returns_had_language_extensions_false_for_non_langext_template(self): + """expand_language_extensions() returns had_language_extensions=False for non-LE templates.""" + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": {"MyFunc": {"Type": "AWS::Serverless::Function"}}, + } + + result = expand_language_extensions(template) + + self.assertIsInstance(result, LanguageExtensionResult) + self.assertFalse(result.had_language_extensions) + # Template should be unchanged + self.assertIn("MyFunc", result.expanded_template["Resources"]) + + def test_returns_had_language_extensions_false_for_no_transform(self): + """expand_language_extensions() returns had_language_extensions=False when no Transform.""" + template = {"Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}} + + result = expand_language_extensions(template) + + self.assertFalse(result.had_language_extensions) + + def test_original_template_preserves_foreach_structure(self): + """expand_language_extensions() preserves Fn::ForEach in original_template.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "src/", "Handler": "${Name}.handler"}, + } + }, + ] + }, + } + + result = expand_language_extensions(template) + + # Original template should preserve Fn::ForEach + self.assertIn("Fn::ForEach::Services", result.original_template["Resources"]) + # Expanded template should NOT have Fn::ForEach + self.assertNotIn("Fn::ForEach::Services", result.expanded_template["Resources"]) + + def test_original_template_is_deep_copy(self): + """expand_language_extensions() original_template is a deep copy, not a reference.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A"], + {"${Name}Topic": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + + result = expand_language_extensions(template) + + # original_template should not be the same object as the input + self.assertIsNot(result.original_template, template) + # But should have the same content + self.assertEqual(result.original_template["Resources"], template["Resources"]) + + def test_dynamic_artifact_properties_detected(self): + """expand_language_extensions() detects dynamic artifact properties.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + result = expand_language_extensions(template) + + self.assertTrue(len(result.dynamic_artifact_properties) > 0) + prop = result.dynamic_artifact_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + self.assertEqual(prop.loop_variable, "Name") + + def test_pseudo_parameter_extraction(self): + """expand_language_extensions() correctly extracts and uses pseudo-parameters.""" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "MyTopic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": {"Fn::Sub": "Topic in ${AWS::Region}"}, + }, + } + }, + } + parameter_values = {"AWS::Region": "us-west-2", "AWS::AccountId": "123456789012"} + + result = expand_language_extensions(template, parameter_values=parameter_values) + + self.assertTrue(result.had_language_extensions) + # Pseudo-parameter should be resolved + self.assertEqual( + result.expanded_template["Resources"]["MyTopic"]["Properties"]["DisplayName"], + "Topic in us-west-2", + ) + + def test_invalid_template_raises_invalid_sam_document(self): + """expand_language_extensions() maps InvalidTemplateException to InvalidSamDocumentException.""" + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": "invalid_not_a_list", + }, + } + + with self.assertRaises(InvalidSamDocumentException): + expand_language_extensions(template) + + def test_list_transform_with_language_extensions(self): + """expand_language_extensions() works when Transform is a list containing AWS::LanguageExtensions.""" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A", "B"], + {"${Name}Topic": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + + result = expand_language_extensions(template) + + self.assertTrue(result.had_language_extensions) + self.assertIn("ATopic", result.expanded_template["Resources"]) + self.assertIn("BTopic", result.expanded_template["Resources"]) + + +# ============================================================================= +# Task 35.3: Unit Tests Verifying run_plugins() No Longer Calls Phase 1 +# ============================================================================= + + +class TestRunPluginsPhase2Only(TestCase): + """ + Unit tests verifying that SamTranslatorWrapper.run_plugins() no longer + calls Phase 1 logic (_process_language_extensions). + + Validates: Requirements 23.6 + """ + + def test_run_plugins_does_not_call_process_language_extensions(self): + """run_plugins() should not call _process_language_extensions().""" + # Verify the method no longer exists on SamTranslatorWrapper + self.assertFalse( + hasattr(SamTranslatorWrapper, "_process_language_extensions"), + "_process_language_extensions() should have been removed from SamTranslatorWrapper", + ) + + def test_run_plugins_does_not_call_build_pseudo_parameters(self): + """run_plugins() should not call _build_pseudo_parameters().""" + # Verify the method no longer exists on SamTranslatorWrapper + self.assertFalse( + hasattr(SamTranslatorWrapper, "_build_pseudo_parameters"), + "_build_pseudo_parameters() should have been removed from SamTranslatorWrapper", + ) + + def test_run_plugins_works_with_pre_expanded_template(self): + """run_plugins() works correctly with a pre-expanded template (no Fn::ForEach).""" + # This is a pre-expanded template (Phase 1 already done) + expanded_template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Alpha.handler", + "CodeUri": "s3://bucket/code.zip", + "Runtime": "python3.9", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Beta.handler", + "CodeUri": "s3://bucket/code.zip", + "Runtime": "python3.9", + }, + }, + }, + } + + wrapper = SamTranslatorWrapper(expanded_template) + result = wrapper.run_plugins() + + # Should process the template without errors + self.assertIn("AlphaFunction", result["Resources"]) + self.assertIn("BetaFunction", result["Resources"]) + + def test_run_plugins_with_language_extension_result(self): + """run_plugins() accepts LanguageExtensionResult and uses its data.""" + expanded_template = { + "Resources": { + "AlphaFunc": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Alpha.handler", + "CodeUri": "s3://bucket/code.zip", + "Runtime": "python3.9", + }, + }, + }, + } + original_template = { + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["Alpha"], + {"${Name}Func": {"Type": "AWS::Serverless::Function"}}, + ] + } + } + dynamic_props = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Loop", + loop_name="Loop", + loop_variable="Name", + collection=["Alpha"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + ] + + lang_result = LanguageExtensionResult( + expanded_template=expanded_template, + original_template=original_template, + dynamic_artifact_properties=dynamic_props, + had_language_extensions=True, + ) + + wrapper = SamTranslatorWrapper(expanded_template, language_extension_result=lang_result) + + # Verify original template and dynamic properties come from the result + self.assertEqual(wrapper.get_original_template(), original_template) + self.assertEqual(wrapper.get_dynamic_artifact_properties(), dynamic_props) + + def test_run_plugins_no_check_using_language_extension_call(self): + """run_plugins() should not internally call _check_using_language_extension().""" + # We verify this by checking that run_plugins() doesn't have Phase 1 logic + # by running it on a template with AWS::LanguageExtensions transform + # but with already-expanded resources (no Fn::ForEach) + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunc": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Alpha.handler", + "CodeUri": "s3://bucket/code.zip", + "Runtime": "python3.9", + }, + }, + }, + } + + wrapper = SamTranslatorWrapper(template) + # run_plugins should work without trying to expand language extensions + result = wrapper.run_plugins() + self.assertIn("AlphaFunc", result["Resources"]) + + +# ============================================================================= +# Task 35.4: Unit Tests Verifying All Callers Use expand_language_extensions() +# ============================================================================= + + +class TestCallersUseExpandLanguageExtensions(TestCase): + """ + Unit tests verifying that all callers use expand_language_extensions() + instead of their own expansion logic. + + Validates: Requirements 23.7 + """ + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_calls_expand_language_extensions(self, mock_expand, mock_get_template): + """SamLocalStackProvider.get_stacks() calls expand_language_extensions().""" + from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A", "B"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + ] + }, + } + expanded = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + "BFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + }, + } + mock_get_template.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + stacks, _ = SamLocalStackProvider.get_stacks(template_file="template.yaml") + + # Verify expand_language_extensions was called + mock_expand.assert_called_once() + # Verify the expanded template is used + self.assertEqual(stacks[0].template_dict, expanded) + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_no_longer_uses_process_language_extensions_for_stack(self, mock_expand, mock_get_template): + """_process_language_extensions_for_stack() should no longer exist in sam_stack_provider.""" + from samcli.lib.providers import sam_stack_provider + + self.assertFalse( + hasattr(sam_stack_provider, "_process_language_extensions_for_stack"), + "_process_language_extensions_for_stack() should have been removed from sam_stack_provider", + ) + + @patch("samcli.lib.translate.sam_template_validator.expand_language_extensions") + def test_sam_template_validator_calls_expand_language_extensions(self, mock_expand): + """SamTemplateValidator calls expand_language_extensions() in get_translated_template_if_valid().""" + from samcli.lib.translate.sam_template_validator import SamTemplateValidator + + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A"], + {"${Name}Topic": {"Type": "AWS::SNS::Topic"}}, + ] + }, + } + expanded = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": {"ATopic": {"Type": "AWS::SNS::Topic"}}, + } + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + managed_policy_mock = MagicMock() + validator = SamTemplateValidator(template, managed_policy_mock) + + # Call the method that should use expand_language_extensions + # We mock the translator to avoid needing real AWS credentials + with patch.object(validator, "_get_managed_policy_map", return_value={}): + try: + validator.get_translated_template_if_valid() + except Exception: + pass # We only care that expand was called + + mock_expand.assert_called_once() + + def test_sam_template_validator_no_longer_has_process_language_extensions(self): + """SamTemplateValidator should not have _process_language_extensions() method.""" + from samcli.lib.translate.sam_template_validator import SamTemplateValidator + + self.assertFalse( + hasattr(SamTemplateValidator, "_process_language_extensions"), + "_process_language_extensions() should have been removed from SamTemplateValidator", + ) + + @patch("samcli.commands.package.package_context.yaml_parse") + @patch("builtins.open", create=True) + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_package_context_export_calls_expand_language_extensions(self, mock_expand, mock_open, mock_yaml_parse): + """PackageContext._export() calls expand_language_extensions().""" + from samcli.commands.package.package_context import PackageContext + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + ] + }, + } + expanded = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + }, + } + + mock_yaml_parse.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + # Create a minimal PackageContext + ctx = PackageContext.__new__(PackageContext) + ctx.template_file = "template.yaml" + ctx.parameter_overrides = {} + ctx._global_parameter_overrides = {} + ctx.uploaders = MagicMock() + ctx.code_signer = MagicMock() + + # Mock Template to avoid actual file operations + with patch("samcli.commands.package.package_context.Template") as mock_template_cls: + mock_template_instance = MagicMock() + mock_template_instance.export.return_value = expanded + mock_template_cls.return_value = mock_template_instance + + try: + ctx._export("template.yaml", use_json=False) + except Exception: + pass # We only care that expand was called + + mock_expand.assert_called_once() + + def test_package_context_no_longer_has_expand_language_extensions_method(self): + """PackageContext should not have _expand_language_extensions() method.""" + from samcli.commands.package.package_context import PackageContext + + self.assertFalse( + hasattr(PackageContext, "_expand_language_extensions"), + "_expand_language_extensions() should have been removed from PackageContext", + ) + + +# ============================================================================= +# Task 35.5: Parametrized Tests for Phase Separation Correctness +# ============================================================================= + + +class TestProperty10PhaseSeparationSingleExpansion: + """ + Phase Separation — Single Expansion + + For any template, expand_language_extensions() is called exactly once + per unique (path, mtime, params) tuple. When called multiple times with + the same inputs, the cached result is returned without re-expanding. + + **Validates: Requirements 20.1, 20.7** + """ + + @pytest.mark.parametrize( + "loop_name, loop_var, collection", + [ + ("Services", "Name", ["Alpha", "Beta"]), + ("Queues", "QueueName", ["Orders", "Payments", "Notifications"]), + ("Topics", "T", ["A"]), + ], + ) + def test_single_expansion_per_unique_inputs( + self, + loop_name: str, + loop_var: str, + collection: List[str], + ): + """ + For any template, expand_language_extensions() produces + a consistent LanguageExtensionResult. Calling it twice with the same + template (no path/caching) returns equivalent results. + + **Validates: Requirements 20.1, 20.7** + """ + clear_expansion_cache() + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + f"Fn::ForEach::{loop_name}": [ + loop_var, + collection, + { + f"${{{loop_var}}}Resource": { + "Type": "AWS::SNS::Topic", + } + }, + ] + }, + } + + result1 = expand_language_extensions(copy.deepcopy(template)) + result2 = expand_language_extensions(copy.deepcopy(template)) + + assert isinstance(result1, LanguageExtensionResult) + assert isinstance(result2, LanguageExtensionResult) + + assert result1.had_language_extensions is True + assert result2.had_language_extensions is True + + assert result1.expanded_template == result2.expanded_template + assert result1.original_template == result2.original_template + + assert len(result1.expanded_template["Resources"]) == len(collection) + + @pytest.mark.parametrize( + "loop_name, loop_var, collection", + [ + ("Services", "Name", ["Alpha", "Beta"]), + ("Queues", "QueueName", ["Orders"]), + ], + ) + def test_no_expansion_without_language_extensions_transform( + self, + loop_name: str, + loop_var: str, + collection: List[str], + ): + """ + For any template WITHOUT AWS::LanguageExtensions, + expand_language_extensions() returns had_language_extensions=False and + does not modify the template. + + **Validates: Requirements 20.1** + """ + clear_expansion_cache() + + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "MyResource": {"Type": "AWS::SNS::Topic"}, + }, + } + + result = expand_language_extensions(copy.deepcopy(template)) + + assert result.had_language_extensions is False + assert "MyResource" in result.expanded_template["Resources"] + + +class TestProperty11PhaseSeparationResultEquivalence: + """ + Phase Separation — Result Equivalence + + For any template, the LanguageExtensionResult returned by + expand_language_extensions() produces the same expanded_template + as the previous per-component expansion logic. + + **Validates: Requirements 20.7, 20.9** + """ + + @pytest.mark.parametrize( + "loop_name, loop_var, collection", + [ + ("Services", "Name", ["Alpha", "Beta"]), + ("Queues", "QueueName", ["Orders", "Payments", "Notifications"]), + ("Topics", "T", ["A"]), + ], + ) + def test_result_equivalence_expanded_template_matches_direct_processing( + self, + loop_name: str, + loop_var: str, + collection: List[str], + ): + """ + The expanded_template from expand_language_extensions() + is equivalent to calling process_template_for_sam_cli() directly. + + **Validates: Requirements 20.7, 20.9** + """ + from samcli.lib.cfn_language_extensions.sam_integration import process_template_for_sam_cli + + clear_expansion_cache() + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + f"Fn::ForEach::{loop_name}": [ + loop_var, + collection, + { + f"${{{loop_var}}}Resource": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": {"Fn::Sub": f"Topic ${{{loop_var}}}"}, + }, + } + }, + ] + }, + } + + result = expand_language_extensions(copy.deepcopy(template)) + direct_result = process_template_for_sam_cli(copy.deepcopy(template)) + + assert set(result.expanded_template["Resources"].keys()) == set(direct_result["Resources"].keys()) + + for resource_key in result.expanded_template["Resources"]: + assert result.expanded_template["Resources"][resource_key] == direct_result["Resources"][resource_key] + + @pytest.mark.parametrize( + "loop_name, loop_var, collection", + [ + ("Services", "Name", ["Alpha", "Beta"]), + ("Queues", "QueueName", ["Orders", "Payments", "Notifications"]), + ("Topics", "T", ["A"]), + ], + ) + def test_result_preserves_original_template_structure( + self, + loop_name: str, + loop_var: str, + collection: List[str], + ): + """ + The original_template in the result preserves + the Fn::ForEach structure from the input template. + + **Validates: Requirements 20.9** + """ + clear_expansion_cache() + + foreach_key = f"Fn::ForEach::{loop_name}" + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + foreach_key: [ + loop_var, + collection, + { + f"${{{loop_var}}}Resource": { + "Type": "AWS::SNS::Topic", + } + }, + ] + }, + } + + result = expand_language_extensions(copy.deepcopy(template)) + + assert foreach_key in result.original_template["Resources"] + + original_foreach = result.original_template["Resources"][foreach_key] + assert isinstance(original_foreach, list) + assert len(original_foreach) == 3 + assert original_foreach[0] == loop_var + assert original_foreach[1] == collection + + @pytest.mark.parametrize( + "loop_name, loop_var, collection", + [ + ("Services", "Name", ["Alpha", "Beta"]), + ("Queues", "QueueName", ["Orders", "Payments", "Notifications"]), + ("Topics", "T", ["A"]), + ], + ) + def test_result_dynamic_properties_consistent( + self, + loop_name: str, + loop_var: str, + collection: List[str], + ): + """ + Dynamic artifact properties detected by + expand_language_extensions() are consistent with the template content. + + **Validates: Requirements 20.9** + """ + clear_expansion_cache() + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + f"Fn::ForEach::{loop_name}": [ + loop_var, + collection, + { + f"${{{loop_var}}}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"./${{{loop_var}}}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + result = expand_language_extensions(copy.deepcopy(template)) + + assert len(result.dynamic_artifact_properties) > 0 + + prop = result.dynamic_artifact_properties[0] + assert prop.loop_variable == loop_var + assert prop.property_name == "CodeUri" + assert prop.collection == collection + assert prop.loop_name == loop_name diff --git a/tests/unit/lib/cfn_language_extensions/test_property_tests.py b/tests/unit/lib/cfn_language_extensions/test_property_tests.py new file mode 100644 index 00000000000..5a65a48cbc7 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_property_tests.py @@ -0,0 +1,1126 @@ +""" +Unit tests for CloudFormation Language Extensions Integration. + +This module contains unit tests for the correctness properties +defined in the design document: +- Property 3: Original Template Preserved for Output +- Property 4: Dynamic Artifact Property Transformation +- Property 5: Cloud-Dependent Collection Rejection +- Property 6: Locally Resolvable Collection Acceptance +- Property 7: Content-Based S3 Hashing for Dynamic Artifacts + +Requirements tested: + - 3.1, 3.2, 3.3, 3.4: Original template preservation + - 4.1, 4.2, 4.3, 4.4, 4.5: Dynamic artifact property transformation + - 5.1, 5.2, 5.3, 5.4, 5.5: Cloud-dependent collection rejection + - 5.6, 5.7: Locally resolvable collection acceptance +""" + +import copy +from unittest.mock import MagicMock, patch + +import pytest + +from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext +from samcli.lib.cfn_language_extensions.processors.foreach import ForEachProcessor + + +# ============================================================================= +# Property 3: Original Template Preserved for Output +# ============================================================================= + + +class TestProperty3OriginalTemplatePreserved: + """ + Property 3: Original Template Preserved for Output + + For any template with `Fn::ForEach` constructs, after processing, + the `get_original_template()` method SHALL return a template that + preserves the original `Fn::ForEach` structure. + + **Validates: Requirements 3.1, 3.2, 3.3, 3.4** + """ + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection,resource_type", + [ + ("Services", "Name", ["Alpha", "Beta"], "AWS::Serverless::Function"), + ("Tables", "TableName", ["Users", "Orders", "Products"], "AWS::DynamoDB::Table"), + ("Queues", "QName", ["High", "Low"], "AWS::SQS::Queue"), + ], + ) + def test_foreach_structure_preserved_in_original_template( + self, + loop_name, + loop_variable, + collection, + resource_type, + ): + """ + For any template with Fn::ForEach, the original template + preserves the Fn::ForEach structure after processing. + + **Validates: Requirements 3.1, 3.2, 3.3** + """ + from samcli.lib.samlib.wrapper import SamTranslatorWrapper + + foreach_key = f"Fn::ForEach::{loop_name}" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"Resource${{{loop_variable}}}": { + "Type": resource_type, + "Properties": { + "Name": {"Fn::Sub": f"${{{loop_variable}}}-resource"}, + }, + } + }, + ] + }, + } + + original_copy = copy.deepcopy(template) + wrapper = SamTranslatorWrapper(template) + preserved = wrapper.get_original_template() + + # Verify Fn::ForEach structure is preserved + assert foreach_key in preserved["Resources"] + assert preserved == original_copy + + # Verify expanded resources are NOT in the original + for value in collection: + assert f"Resource{value}" not in preserved["Resources"] + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Functions", "Name", ["Alpha", "Beta"]), + ("Workers", "Wk", ["Process", "Notify", "Archive"]), + ], + ) + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_original_template_unchanged_after_run_plugins( + self, + mock_parser_class, + loop_name, + loop_variable, + collection, + ): + """ + For any template with Fn::ForEach, the original template + remains unchanged after run_plugins() processes it. + + **Validates: Requirements 3.2, 3.4** + """ + from samcli.lib.samlib.wrapper import SamTranslatorWrapper + + foreach_key = f"Fn::ForEach::{loop_name}" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + original_copy = copy.deepcopy(template) + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(template) + wrapper.run_plugins() + + preserved = wrapper.get_original_template() + assert preserved == original_copy + assert foreach_key in preserved["Resources"] + + @pytest.mark.parametrize( + "loop_name1,loop_name2,loop_var1,loop_var2,coll1,coll2", + [ + ("Functions", "Tables", "FName", "TName", ["Alpha", "Beta"], ["Users", "Orders"]), + ("APIs", "Queues", "ApiName", "QName", ["Public", "Private"], ["High", "Low", "Medium"]), + ], + ) + def test_multiple_foreach_blocks_preserved( + self, + loop_name1, + loop_name2, + loop_var1, + loop_var2, + coll1, + coll2, + ): + """ + For any template with multiple Fn::ForEach blocks, + all blocks are preserved in the original template. + + **Validates: Requirements 3.3** + """ + from samcli.lib.samlib.wrapper import SamTranslatorWrapper + + foreach_key1 = f"Fn::ForEach::{loop_name1}" + foreach_key2 = f"Fn::ForEach::{loop_name2}" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key1: [ + loop_var1, + coll1, + { + f"Func${{{loop_var1}}}": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler"}, + } + }, + ], + foreach_key2: [ + loop_var2, + coll2, + { + f"Table${{{loop_var2}}}": { + "Type": "AWS::DynamoDB::Table", + "Properties": {"TableName": {"Fn::Sub": f"${{{loop_var2}}}"}}, + } + }, + ], + "StaticResource": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "my-bucket"}, + }, + }, + } + + wrapper = SamTranslatorWrapper(template) + preserved = wrapper.get_original_template() + + assert foreach_key1 in preserved["Resources"] + assert foreach_key2 in preserved["Resources"] + assert "StaticResource" in preserved["Resources"] + + +# ============================================================================= +# Property 5: Cloud-Dependent Collection Rejection +# ============================================================================= + + +class TestProperty5CloudDependentCollectionRejection: + """ + Property 5: Cloud-Dependent Collection Rejection + + For any `Fn::ForEach` collection containing `Fn::GetAtt`, `Fn::ImportValue`, + or SSM/Secrets Manager dynamic references, the language extensions processor + SHALL raise an error with a clear message suggesting the parameter workaround. + + **Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5** + """ + + @pytest.mark.parametrize( + "loop_name,loop_variable,resource_name,attribute_name", + [ + ("Functions", "Name", "MyResource", "Arn"), + ("Services", "Svc", "OutputTable", "OutputList"), + ("Workers", "Wk", "LambdaFunc", "Name"), + ], + ) + def test_fn_getatt_in_collection_raises_error( + self, + loop_name, + loop_variable, + resource_name, + attribute_name, + ): + """ + For any Fn::ForEach with Fn::GetAtt in collection, + an error SHALL be raised with a clear message. + + **Validates: Requirements 5.1, 5.4, 5.5** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + context = TemplateProcessingContext( + fragment={ + "Resources": { + foreach_key: [ + loop_variable, + {"Fn::GetAtt": [resource_name, attribute_name]}, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Fn::GetAtt" in error_message + assert "cannot be resolved locally" in error_message.lower() or "unable to resolve" in error_message.lower() + assert "parameter" in error_message.lower() + + @pytest.mark.parametrize( + "loop_name,loop_variable,export_name", + [ + ("Functions", "Name", "SharedFunctionNames"), + ("Services", "Svc", "CrossStackExport"), + ("Workers", "Wk", "ImportedList"), + ], + ) + def test_fn_importvalue_in_collection_raises_error( + self, + loop_name, + loop_variable, + export_name, + ): + """ + For any Fn::ForEach with Fn::ImportValue in collection, + an error SHALL be raised with a clear message. + + **Validates: Requirements 5.2, 5.4, 5.5** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + context = TemplateProcessingContext( + fragment={ + "Resources": { + foreach_key: [ + loop_variable, + {"Fn::ImportValue": export_name}, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "Fn::ImportValue" in error_message + assert "parameter" in error_message.lower() + + @pytest.mark.parametrize( + "loop_name,loop_variable,ssm_path,service", + [ + ("Functions", "Name", "/my/path", "ssm"), + ("Services", "Svc", "/secrets/list", "ssm-secure"), + ("Workers", "Wk", "/app/config", "secretsmanager"), + ], + ) + def test_dynamic_reference_in_collection_raises_error( + self, + loop_name, + loop_variable, + ssm_path, + service, + ): + """ + For any Fn::ForEach with SSM/Secrets Manager dynamic reference + in collection, an error SHALL be raised with a clear message. + + **Validates: Requirements 5.3, 5.4, 5.5** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + dynamic_ref = f"{{{{resolve:{service}:{ssm_path}}}}}" + + context = TemplateProcessingContext( + fragment={ + "Resources": { + foreach_key: [ + loop_variable, + [dynamic_ref], + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + } + } + ) + + with pytest.raises(InvalidTemplateException) as exc_info: + processor.process_template(context) + + error_message = str(exc_info.value) + assert "cannot be resolved locally" in error_message.lower() or "unable to resolve" in error_message.lower() + assert "parameter" in error_message.lower() + + +# ============================================================================= +# Property 6: Locally Resolvable Collection Acceptance +# ============================================================================= + + +class TestProperty6LocallyResolvableCollectionAcceptance: + """ + Property 6: Locally Resolvable Collection Acceptance + + For any `Fn::ForEach` collection that is a static list or a `!Ref` to a + parameter with a provided value, the language extensions processor SHALL + successfully resolve and expand the collection. + + **Validates: Requirements 5.6, 5.7** + """ + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Functions", "Name", ["Alpha", "Beta"]), + ("Services", "Svc", ["Users", "Orders", "Products"]), + ("Queues", "QName", ["High"]), + ], + ) + def test_static_list_collection_succeeds( + self, + loop_name, + loop_variable, + collection, + ): + """ + For any Fn::ForEach with a static list collection, + the processor SHALL successfully expand the collection. + + **Validates: Requirements 5.6** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + context = TemplateProcessingContext( + fragment={ + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": {"Fn::Sub": f"${{{loop_variable}}}-topic"}, + }, + } + }, + ] + } + } + ) + + processor.process_template(context) + + assert foreach_key not in context.fragment["Resources"] + for value in collection: + assert f"Resource{value}" in context.fragment["Resources"] + assert len(context.fragment["Resources"]) == len(collection) + + @pytest.mark.parametrize( + "loop_name,loop_variable,param_name,collection", + [ + ("Functions", "Name", "FuncNames", ["Alpha", "Beta"]), + ("Services", "Svc", "ServiceList", ["Users", "Orders", "Products"]), + ], + ) + def test_parameter_ref_collection_with_override_succeeds( + self, + loop_name, + loop_variable, + param_name, + collection, + ): + """ + For any Fn::ForEach with a parameter reference collection + and provided parameter value, the processor SHALL successfully expand. + + **Validates: Requirements 5.7** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + context = TemplateProcessingContext( + fragment={ + "Parameters": { + param_name: { + "Type": "CommaDelimitedList", + "Default": "Default1,Default2", + } + }, + "Resources": { + foreach_key: [ + loop_variable, + {"Ref": param_name}, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + }, + }, + parameter_values={param_name: collection}, + ) + + processor.process_template(context) + + assert foreach_key not in context.fragment["Resources"] + for value in collection: + assert f"Resource{value}" in context.fragment["Resources"] + + @pytest.mark.parametrize( + "loop_name,loop_variable,param_name,collection", + [ + ("Functions", "Name", "FuncNames", ["Alpha", "Beta"]), + ("Services", "Svc", "ServiceList", ["Users", "Orders", "Products"]), + ], + ) + def test_parameter_ref_collection_with_default_succeeds( + self, + loop_name, + loop_variable, + param_name, + collection, + ): + """ + For any Fn::ForEach with a parameter reference collection + and default value, the processor SHALL successfully expand using the default. + + **Validates: Requirements 5.7** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + default_value = ",".join(collection) + + context = TemplateProcessingContext( + fragment={ + "Parameters": { + param_name: { + "Type": "CommaDelimitedList", + "Default": default_value, + } + }, + "Resources": { + foreach_key: [ + loop_variable, + {"Ref": param_name}, + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + }, + }, + parameter_values={}, + ) + + processor.process_template(context) + + assert foreach_key not in context.fragment["Resources"] + for value in collection: + assert f"Resource{value}" in context.fragment["Resources"] + + @pytest.mark.parametrize( + "loop_name,loop_variable", + [ + ("Functions", "Name"), + ("Services", "Svc"), + ], + ) + def test_empty_collection_produces_no_resources( + self, + loop_name, + loop_variable, + ): + """ + For any Fn::ForEach with an empty collection, + the processor SHALL produce no resources. + + **Validates: Requirements 5.6** + """ + processor = ForEachProcessor() + foreach_key = f"Fn::ForEach::{loop_name}" + context = TemplateProcessingContext( + fragment={ + "Resources": { + foreach_key: [ + loop_variable, + [], + { + f"Resource${{{loop_variable}}}": { + "Type": "AWS::SNS::Topic", + } + }, + ] + } + } + ) + + processor.process_template(context) + + assert foreach_key not in context.fragment["Resources"] + assert len(context.fragment["Resources"]) == 0 + + +# ============================================================================= +# Property 4: Dynamic Artifact Property Transformation +# ============================================================================= + + +class TestProperty4DynamicArtifactPropertyTransformation: + """ + Property 4: Dynamic Artifact Property Transformation + + For any `Fn::ForEach` block that generates a packageable resource type + with a dynamic artifact property (containing the loop variable), after + `sam package`: + - A Mappings section SHALL be generated with S3 URIs for each collection value + - The artifact property SHALL be replaced with `Fn::FindInMap` referencing + the generated Mappings + - The `Fn::ForEach` structure SHALL be preserved + + **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** + """ + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Services", "Name", ["Users", "Orders"]), + ("Functions", "FuncName", ["Alpha", "Beta", "Gamma"]), + ("Workers", "Wk", ["Process", "Notify"]), + ], + ) + def test_dynamic_codeuri_detected( + self, + loop_name, + loop_variable, + collection, + ): + """ + For any Fn::ForEach with dynamic CodeUri (containing loop variable), + the dynamic artifact property SHALL be detected. + + **Validates: Requirements 4.1** + """ + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + foreach_key = f"Fn::ForEach::{loop_name}" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"${{{loop_variable}}}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"./services/${{{loop_variable}}}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + dynamic_props = detect_dynamic_artifact_properties(template) + + assert len(dynamic_props) == 1 + prop = dynamic_props[0] + assert prop.foreach_key == foreach_key + assert prop.loop_variable == loop_variable + assert prop.property_name == "CodeUri" + assert prop.collection == collection + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Services", "Name", ["Users", "Orders"]), + ("Functions", "FuncName", ["Alpha", "Beta", "Gamma"]), + ], + ) + def test_static_codeuri_not_detected_as_dynamic( + self, + loop_name, + loop_variable, + collection, + ): + """ + For any Fn::ForEach with static CodeUri (not containing loop variable), + the artifact property SHALL NOT be detected as dynamic. + + **Validates: Requirements 4.7** + """ + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + foreach_key = f"Fn::ForEach::{loop_name}" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"${{{loop_variable}}}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": f"${{{loop_variable}}}.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + dynamic_props = detect_dynamic_artifact_properties(template) + assert len(dynamic_props) == 0 + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Services", "Name", ["Users", "Orders"]), + ("Layers", "LayerName", ["Common", "Utils", "Shared"]), + ], + ) + def test_mappings_naming_convention( + self, + loop_name, + loop_variable, + collection, + ): + """ + For any dynamic artifact property, the generated Mappings + SHALL follow the naming convention SAM{PropertyName}{LoopName}. + + **Validates: Requirements 4.6** + """ + from samcli.commands.package.package_context import PackageContext + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + prop = DynamicArtifactProperty( + foreach_key=f"Fn::ForEach::{loop_name}", + loop_name=loop_name, + loop_variable=loop_variable, + collection=collection, + resource_key=f"${{{loop_variable}}}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value=f"./services/${{{loop_variable}}}", + collection_is_parameter_ref=False, + collection_parameter_name=None, + ) + + ctx = PackageContext( + template_file="template.yaml", + s3_bucket="test-bucket", + image_repository=None, + image_repositories=None, + s3_prefix=None, + kms_key_id=None, + output_template_file="output.yaml", + use_json=False, + force_upload=False, + no_progressbar=True, + metadata=None, + region="us-east-1", + profile=None, + ) + + exported_resources = {} + for value in collection: + expanded_key = f"{value}Function" + exported_resources[expanded_key] = { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"s3://test-bucket/{value}-hash.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + + mappings, _ = ctx._generate_artifact_mappings([prop], "/tmp", exported_resources) + + expected_mapping_name = f"SAMCodeUri{loop_name}" + assert expected_mapping_name in mappings + + for value in collection: + assert value in mappings[expected_mapping_name] + + +# ============================================================================= +# Property 7: Content-Based S3 Hashing for Dynamic Artifacts +# ============================================================================= + + +class TestProperty7ContentBasedS3Hashing: + """ + Property 7: Content-Based S3 Hashing for Dynamic Artifacts + + For any dynamic artifact property in `Fn::ForEach`, each generated artifact + SHALL have a unique S3 key based on content hash. + + **Validates: Requirements 4.2** + """ + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Services", "Name", ["Users", "Orders"]), + ("Functions", "FuncName", ["Alpha", "Beta", "Gamma"]), + ], + ) + def test_each_collection_value_gets_unique_s3_uri( + self, + loop_name, + loop_variable, + collection, + ): + """ + For any dynamic artifact property, each collection value + SHALL have a unique S3 URI in the generated Mappings. + + **Validates: Requirements 4.2** + """ + from samcli.commands.package.package_context import PackageContext + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + prop = DynamicArtifactProperty( + foreach_key=f"Fn::ForEach::{loop_name}", + loop_name=loop_name, + loop_variable=loop_variable, + collection=collection, + resource_key=f"${{{loop_variable}}}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value=f"./services/${{{loop_variable}}}", + collection_is_parameter_ref=False, + collection_parameter_name=None, + ) + + ctx = PackageContext( + template_file="template.yaml", + s3_bucket="test-bucket", + image_repository=None, + image_repositories=None, + s3_prefix=None, + kms_key_id=None, + output_template_file="output.yaml", + use_json=False, + force_upload=False, + no_progressbar=True, + metadata=None, + region="us-east-1", + profile=None, + ) + + exported_resources = {} + for i, value in enumerate(collection): + expanded_key = f"{value}Function" + content_hash = f"hash{i:04d}" + exported_resources[expanded_key] = { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"s3://test-bucket/{content_hash}.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + + mappings, _ = ctx._generate_artifact_mappings([prop], "/tmp", exported_resources) + + mapping_name = f"SAMCodeUri{loop_name}" + s3_uris = set() + + for value in collection: + assert value in mappings[mapping_name] + s3_uri = mappings[mapping_name][value].get("CodeUri") + assert s3_uri is not None + assert s3_uri not in s3_uris + s3_uris.add(s3_uri) + + assert len(s3_uris) == len(collection) + + @pytest.mark.parametrize( + "loop_name,loop_variable,collection", + [ + ("Services", "Name", ["Users", "Orders"]), + ("Workers", "Wk", ["Process", "Notify", "Archive"]), + ], + ) + def test_findmap_replacement_preserves_foreach_structure( + self, + loop_name, + loop_variable, + collection, + ): + """ + After replacing dynamic artifact property with Fn::FindInMap, + the Fn::ForEach structure SHALL be preserved. + + **Validates: Requirements 4.4, 4.5** + """ + from samcli.commands.package.package_context import PackageContext + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + foreach_key = f"Fn::ForEach::{loop_name}" + + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + foreach_key: [ + loop_variable, + collection, + { + f"${{{loop_variable}}}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"./services/${{{loop_variable}}}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + prop = DynamicArtifactProperty( + foreach_key=foreach_key, + loop_name=loop_name, + loop_variable=loop_variable, + collection=collection, + resource_key=f"${{{loop_variable}}}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value=f"./services/${{{loop_variable}}}", + collection_is_parameter_ref=False, + collection_parameter_name=None, + ) + + mappings = { + f"SAMCodeUri{loop_name}": {value: {"CodeUri": f"s3://test-bucket/{value}-hash.zip"} for value in collection} + } + + ctx = PackageContext( + template_file="template.yaml", + s3_bucket="test-bucket", + image_repository=None, + image_repositories=None, + s3_prefix=None, + kms_key_id=None, + output_template_file="output.yaml", + use_json=False, + force_upload=False, + no_progressbar=True, + metadata=None, + region="us-east-1", + profile=None, + ) + + result = ctx._apply_artifact_mappings_to_template(copy.deepcopy(template), mappings, [prop]) + + assert foreach_key in result["Resources"] + assert "Mappings" in result + assert f"SAMCodeUri{loop_name}" in result["Mappings"] + + foreach_value = result["Resources"][foreach_key] + assert isinstance(foreach_value, list) + assert len(foreach_value) == 3 + assert foreach_value[0] == loop_variable + assert foreach_value[1] == collection + + +# ============================================================================= +# Property 12: Recursive Detection of Nested ForEach Dynamic Artifact Properties +# ============================================================================= + + +class TestProperty12RecursiveDetection: + """ + Property 12: For any template containing nested Fn::ForEach blocks where an + inner block generates a packageable resource with a dynamic artifact property, + detect_dynamic_artifact_properties() SHALL detect the dynamic property + regardless of nesting depth. + + Validates: Requirements 25.1, 25.10 + """ + + @pytest.mark.parametrize( + "outer_loop_name,inner_loop_name,outer_var,inner_var,outer_collection,inner_collection", + [ + ("Envs", "Services", "Env", "Svc", ["dev", "prod"], ["Users", "Orders"]), + ("Regions", "Functions", "Region", "Func", ["east", "west"], ["Alpha", "Beta", "Gamma"]), + ], + ) + def test_nested_foreach_dynamic_property_detected( + self, + outer_loop_name, + inner_loop_name, + outer_var, + inner_var, + outer_collection, + inner_collection, + ): + """Validates: Requirements 25.1, 25.10""" + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + f"Fn::ForEach::{outer_loop_name}": [ + outer_var, + outer_collection, + { + f"Fn::ForEach::{inner_loop_name}": [ + inner_var, + inner_collection, + { + f"${{{outer_var}}}${{{inner_var}}}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": f"./services/${{{inner_var}}}", + "Handler": "index.handler", + }, + } + }, + ] + }, + ] + } + } + + result = detect_dynamic_artifact_properties(template) + assert len(result) == 1 + assert result[0].loop_variable == inner_var + assert result[0].loop_name == inner_loop_name + assert len(result[0].outer_loops) == 1 + assert result[0].outer_loops[0][1] == outer_var + + +# ============================================================================= +# Property 13: Inner-Only Variable Mappings Naming Convention +# ============================================================================= + + +class TestProperty13InnerOnlyMappingsNaming: + """ + Property 13: For any nested Fn::ForEach where the dynamic artifact property + references only the innermost loop variable, the generated Mappings section + name SHALL follow the pattern SAM{PropertyName}{InnerLoopName}. + + Validates: Requirements 25.2 + """ + + @pytest.mark.parametrize( + "inner_loop_name,inner_collection", + [ + ("Services", ["Users", "Orders"]), + ("Workers", ["Process", "Notify", "Archive"]), + ], + ) + def test_inner_only_mappings_naming(self, inner_loop_name, inner_collection): + """Validates: Requirements 25.2""" + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + "Fn::ForEach::Outer": [ + "OuterVar", + ["a", "b"], + { + f"Fn::ForEach::{inner_loop_name}": [ + "InnerVar", + inner_collection, + { + "${OuterVar}${InnerVar}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./svc/${InnerVar}"}, + } + }, + ] + }, + ] + } + } + + result = detect_dynamic_artifact_properties(template) + assert len(result) == 1 + expected_mapping_name = f"SAMCodeUri{inner_loop_name}" + actual_mapping_name = f"SAM{result[0].property_name}{result[0].loop_name}" + assert actual_mapping_name == expected_mapping_name + + +# ============================================================================= +# Property 14: Compound Key Generation for Multi-Variable References +# ============================================================================= + + +class TestProperty14CompoundKeyGeneration: + """ + Property 14: For any nested Fn::ForEach where the dynamic artifact property + references both outer and inner loop variables, the generated Mappings SHALL + use compound keys. + + Validates: Requirements 25.3, 25.7 + """ + + def test_compound_keys_generated_for_multi_variable_reference(self): + """Validates: Requirements 25.3, 25.7""" + from samcli.lib.cfn_language_extensions.sam_integration import ( + detect_dynamic_artifact_properties, + contains_loop_variable, + ) + + template = { + "Resources": { + "Fn::ForEach::Envs": [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Svcs": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./${Env}/${Svc}"}, + } + }, + ] + }, + ] + } + } + + result = detect_dynamic_artifact_properties(template) + assert len(result) == 1 + prop = result[0] + + assert contains_loop_variable(prop.property_value, "Svc") + assert contains_loop_variable(prop.property_value, "Env") + + assert len(prop.outer_loops) == 1 + assert prop.outer_loops[0][1] == "Env" + assert prop.outer_loops[0][2] == ["dev", "prod"] diff --git a/tests/unit/lib/cfn_language_extensions/test_sam_integration.py b/tests/unit/lib/cfn_language_extensions/test_sam_integration.py new file mode 100644 index 00000000000..596993d37e5 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_sam_integration.py @@ -0,0 +1,1083 @@ +""" +Tests for SAM Translator and SAM CLI integration. + +This module contains unit tests for the SAM integration components: +- SAMLanguageExtensionsPlugin +- process_template_for_sam_cli +- process_nested_stacks + +Requirements tested: + - 14.1-14.6: SAM Translator plugin integration + - 15.1-15.6: SAM CLI integration +""" + +from typing import Any, Dict, List + +import pytest + +from samcli.lib.cfn_language_extensions import ( + SAMLanguageExtensionsPlugin, + process_template_for_sam_cli, + process_nested_stacks, + AWS_LANGUAGE_EXTENSIONS_TRANSFORM, + PseudoParameterValues, +) + + +# ============================================================================= +# Tests for SAM Integration +# ============================================================================= + + +class TestSAMLanguageExtensionsPluginProperties: + """Tests for SAMLanguageExtensionsPlugin.""" + + @pytest.mark.parametrize( + "transforms,resource_id,resource_type", + [ + (["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], "MyTopic", "AWS::SNS::Topic"), + (["AWS::LanguageExtensions"], "MyQueue", "AWS::SQS::Queue"), + ( + ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31", "AWS::Include"], + "MyFunc", + "AWS::Lambda::Function", + ), + ], + ) + def test_transform_ordering_property( + self, + transforms: List[str], + resource_id: str, + resource_type: str, + ): + """ + Property 18: SAM Integration Transform Ordering + + For any template with both AWS::LanguageExtensions and other transforms, + the language extensions SHALL be processed and removed from the transform + list before the template is returned. + + **Validates: Requirements 14.2, 14.5** + """ + # Build a template with the given transforms + template: Dict[str, Any] = { + "Resources": { + resource_id: { + "Type": resource_type, + } + } + } + + if transforms: + if len(transforms) == 1: + template["Transform"] = transforms[0] + else: + template["Transform"] = transforms + + # Process with the plugin + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # Verify AWS::LanguageExtensions is removed + result_transforms = result.get("Transform", []) + if isinstance(result_transforms, str): + result_transforms = [result_transforms] + + assert AWS_LANGUAGE_EXTENSIONS_TRANSFORM not in result_transforms + + # Verify other transforms are preserved + expected_transforms = [t for t in transforms if t != AWS_LANGUAGE_EXTENSIONS_TRANSFORM] + + if len(expected_transforms) == 0: + assert "Transform" not in result + elif len(expected_transforms) == 1: + assert result.get("Transform") == expected_transforms[0] + else: + assert result.get("Transform") == expected_transforms + + @pytest.mark.parametrize( + "collection,identifier", + [ + (["Alpha", "Beta"], "Name"), + (["X", "Y", "Z"], "Item"), + (["Svc1"], "Service"), + ], + ) + def test_foreach_expansion_before_sam_transform( + self, + collection: List[str], + identifier: str, + ): + """ + Property: ForEach expansion happens before SAM transform. + + For any template with Fn::ForEach and AWS::Serverless transforms, + the ForEach SHALL be expanded before the template is returned. + + **Validates: Requirements 14.3, 14.5** + """ + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + f"Fn::ForEach::{identifier}Loop": [ + identifier, + collection, + { + f"Resource${{{identifier}}}": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "python3.9", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # ForEach should be expanded + assert f"Fn::ForEach::{identifier}Loop" not in result["Resources"] + + # Each collection item should produce a resource + for item in collection: + expected_key = f"Resource{item}" + assert expected_key in result["Resources"], f"Expected resource {expected_key} not found" + + # Should have exactly len(collection) resources + assert len(result["Resources"]) == len(collection) + + # AWS::LanguageExtensions should be removed + assert result.get("Transform") == "AWS::Serverless-2016-10-31" + + +class TestProcessTemplateForSAMCLIProperties: + """Tests for process_template_for_sam_cli.""" + + @pytest.mark.parametrize( + "param_name,param_value,resource_id", + [ + ("Environment", "prod", "MyTopic"), + ("AppName", "myapp", "AppResource"), + ("Stage", "dev", "StageRes"), + ], + ) + def test_parameter_resolution_in_sam_cli( + self, + param_name: str, + param_value: str, + resource_id: str, + ): + """ + Property: Parameter values are resolved in SAM CLI processing. + + For any template with Ref to parameters, the parameter values + SHALL be substituted when provided. + + **Validates: Requirements 15.1, 15.2, 15.3** + """ + template = { + "Parameters": { + param_name: { + "Type": "String", + "Default": "default-value", + } + }, + "Resources": {resource_id: {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": {"Ref": param_name}}}}, + } + + result = process_template_for_sam_cli(template, parameter_values={param_name: param_value}) + + # Parameter should be resolved + assert result["Resources"][resource_id]["Properties"]["TopicName"] == param_value + + @pytest.mark.parametrize( + "region,resource_id", + [ + ("us-east-1", "MyTopic"), + ("eu-west-1", "EuTopic"), + ("ap-southeast-1", "ApTopic"), + ], + ) + def test_pseudo_parameter_resolution_in_sam_cli( + self, + region: str, + resource_id: str, + ): + """ + Property: Pseudo-parameters are resolved in SAM CLI processing. + + For any template with Ref to pseudo-parameters, the values + SHALL be substituted when provided. + + **Validates: Requirements 15.1, 15.4** + """ + template = { + "Resources": { + resource_id: {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": {"Ref": "AWS::Region"}}} + } + } + + pseudo = PseudoParameterValues(region=region, account_id="123456789012") + + result = process_template_for_sam_cli(template, pseudo_parameters=pseudo) + + # Pseudo-parameter should be resolved + assert result["Resources"][resource_id]["Properties"]["DisplayName"] == region + + +# ============================================================================= +# Unit Tests for SAM Integration +# ============================================================================= + + +class TestSAMLanguageExtensionsPlugin: + """Unit tests for SAMLanguageExtensionsPlugin.""" + + def test_plugin_processes_language_extensions(self): + """ + Requirement 14.2: Language extensions processed before SAM transform. + """ + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Topics": [ + "Name", + ["A", "B"], + {"Topic${Name}": {"Type": "AWS::Serverless::Function", "Properties": {"Runtime": "python3.9"}}}, + ] + }, + } + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # ForEach should be expanded + assert "TopicA" in result["Resources"] + assert "TopicB" in result["Resources"] + assert "Fn::ForEach::Topics" not in result["Resources"] + + # AWS::LanguageExtensions should be removed from transforms + assert result["Transform"] == "AWS::Serverless-2016-10-31" + + def test_plugin_preserves_unresolvable_refs(self): + """ + Requirement 14.5: Unresolvable refs preserved for SAM processing. + """ + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::GetAtt": ["OtherResource", "Arn"]}, + "Environment": {"Variables": {"TABLE_NAME": {"Ref": "MyTable"}}}, + }, + }, + "MyTable": {"Type": "AWS::DynamoDB::Table", "Properties": {"TableName": "test-table"}}, + }, + } + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # Fn::GetAtt should be preserved (unresolvable) + assert result["Resources"]["MyFunction"]["Properties"]["FunctionName"] == { + "Fn::GetAtt": ["OtherResource", "Arn"] + } + + # Ref to resource should be preserved (unresolvable) + assert result["Resources"]["MyFunction"]["Properties"]["Environment"]["Variables"]["TABLE_NAME"] == { + "Ref": "MyTable" + } + + def test_plugin_with_no_language_extensions_transform(self): + """ + Requirement 14.2: No processing if AWS::LanguageExtensions not in transforms. + """ + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": {"MyFunction": {"Type": "AWS::Serverless::Function", "Properties": {"Runtime": "python3.9"}}}, + } + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # Template should be unchanged + assert result == template + + def test_plugin_with_single_language_extensions_transform(self): + """ + Requirement 14.4: Remove AWS::LanguageExtensions when it's the only transform. + """ + template = {"Transform": "AWS::LanguageExtensions", "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}} + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # Transform should be removed entirely + assert "Transform" not in result + + def test_plugin_with_parameter_values(self): + """ + Requirement 14.3: Parameter values used during processing. + """ + template = { + "Transform": "AWS::LanguageExtensions", + "Parameters": {"Environment": {"Type": "String"}}, + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": {"Ref": "Environment"}}}}, + } + + plugin = SAMLanguageExtensionsPlugin(parameter_values={"Environment": "prod"}) + result = plugin.on_before_transform_template(template) + + # Parameter should be resolved + assert result["Resources"]["MyTopic"]["Properties"]["TopicName"] == "prod" + + def test_plugin_with_no_transform(self): + """ + Requirement 14.2: No processing if no Transform field. + """ + template = {"Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}} + + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + + # Template should be unchanged + assert result == template + + +class TestProcessTemplateForSAMCLI: + """Unit tests for process_template_for_sam_cli.""" + + def test_sam_cli_processes_foreach(self): + """ + Requirement 15.1: SAM CLI processes Fn::ForEach. + """ + template = { + "Resources": { + "Fn::ForEach::Queues": [ + "QueueName", + ["Orders", "Notifications"], + {"Queue${QueueName}": {"Type": "AWS::SQS::Queue"}}, + ] + } + } + + result = process_template_for_sam_cli(template) + + assert "QueueOrders" in result["Resources"] + assert "QueueNotifications" in result["Resources"] + assert "Fn::ForEach::Queues" not in result["Resources"] + + def test_sam_cli_preserves_transform(self): + """ + Requirement 15.5: SAM CLI preserves Transform field. + """ + template = {"Transform": "AWS::LanguageExtensions", "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}} + + result = process_template_for_sam_cli(template) + + # Transform should be preserved (unlike plugin) + assert result.get("Transform") == "AWS::LanguageExtensions" + + def test_sam_cli_with_pseudo_parameters(self): + """ + Requirement 15.1: SAM CLI uses pseudo-parameters. + """ + template = { + "Resources": { + "MyTopic": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Sub": "Topic in ${AWS::Region}"}}, + } + } + } + + pseudo = PseudoParameterValues(region="us-west-2", account_id="123456789012") + + result = process_template_for_sam_cli(template, pseudo_parameters=pseudo) + + assert result["Resources"]["MyTopic"]["Properties"]["DisplayName"] == "Topic in us-west-2" + + def test_sam_cli_partial_resolution(self): + """ + Requirement 15.2, 15.4: SAM CLI uses partial resolution mode. + """ + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": {"FunctionName": {"Fn::GetAtt": ["MyTable", "Arn"]}}, + }, + "MyTable": {"Type": "AWS::DynamoDB::Table"}, + } + } + + result = process_template_for_sam_cli(template) + + # Fn::GetAtt should be preserved + assert result["Resources"]["MyFunction"]["Properties"]["FunctionName"] == {"Fn::GetAtt": ["MyTable", "Arn"]} + + +class TestProcessNestedStacks: + """Unit tests for process_nested_stacks.""" + + def test_nested_stacks_processed_independently(self): + """ + Requirement 15.6: Process each nested stack independently. + """ + main_template = { + "Resources": { + "Fn::ForEach::MainResources": ["Name", ["A", "B"], {"Main${Name}": {"Type": "AWS::SNS::Topic"}}] + } + } + + nested_templates = { + "VPCStack": { + "Resources": {"Fn::ForEach::Subnets": ["AZ", ["1", "2"], {"Subnet${AZ}": {"Type": "AWS::EC2::Subnet"}}]} + } + } + + results = process_nested_stacks(main_template, nested_templates=nested_templates) + + # Main template should be processed + assert "MainA" in results["MainTemplate"]["Resources"] + assert "MainB" in results["MainTemplate"]["Resources"] + + # Nested template should be processed + assert "Subnet1" in results["VPCStack"]["Resources"] + assert "Subnet2" in results["VPCStack"]["Resources"] + + def test_nested_stacks_share_pseudo_parameters(self): + """ + Requirement 15.6: Nested stacks share pseudo-parameters. + """ + main_template = { + "Resources": { + "MainTopic": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": {"Ref": "AWS::Region"}}} + } + } + + nested_templates = { + "NestedStack": { + "Resources": { + "NestedTopic": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": {"Ref": "AWS::Region"}}} + } + } + } + + pseudo = PseudoParameterValues(region="eu-west-1", account_id="123456789012") + + results = process_nested_stacks(main_template, pseudo_parameters=pseudo, nested_templates=nested_templates) + + # Both should use the same region + assert results["MainTemplate"]["Resources"]["MainTopic"]["Properties"]["DisplayName"] == "eu-west-1" + assert results["NestedStack"]["Resources"]["NestedTopic"]["Properties"]["DisplayName"] == "eu-west-1" + + def test_nested_stacks_with_no_nested_templates(self): + """ + Requirement 15.6: Works with no nested templates. + """ + main_template = {"Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}} + + results = process_nested_stacks(main_template) + + assert "MainTemplate" in results + assert len(results) == 1 + + +class TestSAMLanguageExtensionsPluginNullTransform: + """Tests for SAMLanguageExtensionsPlugin with null Transform.""" + + def test_plugin_with_null_transform(self): + """Test plugin when Transform key exists but is None.""" + from samcli.lib.cfn_language_extensions.sam_integration import SAMLanguageExtensionsPlugin + + template = { + "Transform": None, + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic"}}, + } + plugin = SAMLanguageExtensionsPlugin() + result = plugin.on_before_transform_template(template) + assert result == template + + +# ============================================================================= +# Coverage Tests for sam_integration.py +# ============================================================================= + +import os +import tempfile +from unittest.mock import patch + +from samcli.lib.cfn_language_extensions.sam_integration import ( + LanguageExtensionResult, + _build_pseudo_parameters, + _hash_params, + check_using_language_extension, + clear_expansion_cache, + contains_loop_variable, + detect_dynamic_artifact_properties, + detect_foreach_dynamic_properties, + expand_language_extensions, + resolve_collection, + resolve_parameter_collection, +) + + +class TestBuildPseudoParameters: + """Tests for _build_pseudo_parameters.""" + + def test_returns_none_for_none_input(self): + assert _build_pseudo_parameters(None) is None + + def test_returns_none_for_empty_dict(self): + assert _build_pseudo_parameters({}) is None + + def test_returns_none_for_no_pseudo_params(self): + assert _build_pseudo_parameters({"MyParam": "value"}) is None + + def test_extracts_region(self): + result = _build_pseudo_parameters({"AWS::Region": "us-east-1"}) + assert result is not None + assert result.region == "us-east-1" + + def test_extracts_account_id(self): + result = _build_pseudo_parameters({"AWS::AccountId": "123456789012"}) + assert result is not None + assert result.account_id == "123456789012" + + def test_extracts_stack_name(self): + result = _build_pseudo_parameters({"AWS::StackName": "my-stack"}) + assert result is not None + assert result.stack_name == "my-stack" + + def test_extracts_stack_id(self): + result = _build_pseudo_parameters({"AWS::StackId": "arn:aws:cloudformation:us-east-1:123:stack/my-stack/guid"}) + assert result is not None + assert result.stack_id == "arn:aws:cloudformation:us-east-1:123:stack/my-stack/guid" + + def test_extracts_partition(self): + result = _build_pseudo_parameters({"AWS::Partition": "aws"}) + assert result is not None + assert result.partition == "aws" + + def test_extracts_url_suffix(self): + result = _build_pseudo_parameters({"AWS::URLSuffix": "amazonaws.com"}) + assert result is not None + assert result.url_suffix == "amazonaws.com" + + def test_extracts_all_pseudo_params(self): + result = _build_pseudo_parameters( + { + "AWS::Region": "us-west-2", + "AWS::AccountId": "111222333444", + "AWS::StackName": "test-stack", + "AWS::StackId": "arn:stack-id", + "AWS::Partition": "aws-cn", + "AWS::URLSuffix": "amazonaws.com.cn", + } + ) + assert result is not None + assert result.region == "us-west-2" + assert result.account_id == "111222333444" + assert result.stack_name == "test-stack" + assert result.stack_id == "arn:stack-id" + assert result.partition == "aws-cn" + assert result.url_suffix == "amazonaws.com.cn" + + def test_missing_pseudo_params_default_to_empty_string_or_none(self): + result = _build_pseudo_parameters({"AWS::Region": "us-east-1"}) + assert result is not None + assert result.region == "us-east-1" + assert result.account_id == "" + assert result.stack_name is None + assert result.stack_id is None + assert result.partition is None + assert result.url_suffix is None + + +class TestContainsLoopVariable: + """Tests for contains_loop_variable.""" + + def test_string_with_variable(self): + assert contains_loop_variable("./src/${Name}", "Name") is True + + def test_string_without_variable(self): + assert contains_loop_variable("./src/static", "Name") is False + + def test_ref_dict_matching(self): + assert contains_loop_variable({"Ref": "Name"}, "Name") is True + + def test_ref_dict_not_matching(self): + assert contains_loop_variable({"Ref": "Other"}, "Name") is False + + def test_fn_sub_string(self): + assert contains_loop_variable({"Fn::Sub": "./src/${Name}"}, "Name") is True + + def test_fn_sub_string_no_match(self): + assert contains_loop_variable({"Fn::Sub": "./src/static"}, "Name") is False + + def test_fn_sub_list_form(self): + assert contains_loop_variable({"Fn::Sub": ["./src/${Name}", {}]}, "Name") is True + + def test_fn_sub_list_form_no_match(self): + assert contains_loop_variable({"Fn::Sub": ["./src/static", {}]}, "Name") is False + + def test_nested_dict(self): + assert contains_loop_variable({"Nested": {"Deep": "./src/${Name}"}}, "Name") is True + + def test_list_with_variable(self): + assert contains_loop_variable(["./src/${Name}", "other"], "Name") is True + + def test_list_without_variable(self): + assert contains_loop_variable(["static", "other"], "Name") is False + + def test_non_string_non_dict_non_list(self): + assert contains_loop_variable(42, "Name") is False + assert contains_loop_variable(None, "Name") is False + assert contains_loop_variable(True, "Name") is False + + def test_fn_sub_empty_list(self): + assert contains_loop_variable({"Fn::Sub": []}, "Name") is False + + +class TestResolveCollection: + """Tests for resolve_collection and resolve_parameter_collection.""" + + def test_static_list(self): + result = resolve_collection(["Alpha", "Beta"], {}) + assert result == ["Alpha", "Beta"] + + def test_static_list_with_none_values(self): + result = resolve_collection(["Alpha", None, "Beta"], {}) + assert result == ["Alpha", "Beta"] + + def test_static_list_with_integers(self): + result = resolve_collection([1, 2, 3], {}) + assert result == ["1", "2", "3"] + + def test_ref_to_parameter_with_override(self): + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList"}}} + result = resolve_collection({"Ref": "Names"}, template, {"Names": "Alpha,Beta"}) + assert result == ["Alpha", "Beta"] + + def test_ref_to_parameter_with_list_override(self): + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList"}}} + result = resolve_collection({"Ref": "Names"}, template, {"Names": ["Alpha", "Beta"]}) + assert result == ["Alpha", "Beta"] + + def test_ref_to_parameter_with_default(self): + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "X,Y"}}} + result = resolve_collection({"Ref": "Names"}, template) + assert result == ["X", "Y"] + + def test_ref_to_parameter_with_list_default(self): + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": ["X", "Y"]}}} + result = resolve_collection({"Ref": "Names"}, template) + assert result == ["X", "Y"] + + def test_ref_to_nonexistent_parameter(self): + result = resolve_collection({"Ref": "Missing"}, {"Parameters": {}}) + assert result == [] + + def test_unsupported_collection_type(self): + result = resolve_collection("not-a-list-or-dict", {}) + assert result == [] + + def test_non_ref_dict(self): + result = resolve_collection({"Fn::GetAtt": ["Resource", "Attr"]}, {}) + assert result == [] + + def test_ref_to_param_no_parameters_section(self): + result = resolve_collection({"Ref": "Names"}, {}) + assert result == [] + + def test_ref_to_param_non_dict_param_def(self): + template = {"Parameters": {"Names": "not-a-dict"}} + result = resolve_collection({"Ref": "Names"}, template) + assert result == [] + + +class TestResolveParameterCollection: + """Direct tests for resolve_parameter_collection.""" + + def test_override_string_value(self): + result = resolve_parameter_collection("P", {}, {"P": "a, b, c"}) + assert result == ["a", "b", "c"] + + def test_override_list_value(self): + result = resolve_parameter_collection("P", {}, {"P": ["a", "b"]}) + assert result == ["a", "b"] + + def test_default_string_value(self): + result = resolve_parameter_collection("P", {"Parameters": {"P": {"Default": "x,y"}}}) + assert result == ["x", "y"] + + def test_default_list_value(self): + result = resolve_parameter_collection("P", {"Parameters": {"P": {"Default": ["x", "y"]}}}) + assert result == ["x", "y"] + + def test_no_default_no_override(self): + result = resolve_parameter_collection("P", {"Parameters": {"P": {"Type": "String"}}}) + assert result == [] + + def test_no_parameters_section(self): + result = resolve_parameter_collection("P", {}) + assert result == [] + + +class TestDetectForeachDynamicProperties: + """Tests for detect_foreach_dynamic_properties.""" + + def test_detects_dynamic_codeuri(self): + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + template = {"Resources": {}} + result = detect_foreach_dynamic_properties("Fn::ForEach::Services", foreach_value, template) + assert len(result) == 1 + assert result[0].loop_name == "Services" + assert result[0].loop_variable == "Name" + assert result[0].property_name == "CodeUri" + assert result[0].collection == ["Alpha", "Beta"] + + def test_static_codeuri_not_detected(self): + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "${Name}.handler", + }, + } + }, + ] + template = {"Resources": {}} + result = detect_foreach_dynamic_properties("Fn::ForEach::Services", foreach_value, template) + assert len(result) == 0 + + def test_invalid_foreach_value_not_list(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", "not-a-list", {}) + assert result == [] + + def test_invalid_foreach_value_wrong_length(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"]], {}) + assert result == [] + + def test_non_string_loop_variable(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", [123, ["A"], {}], {}) + assert result == [] + + def test_non_dict_output_template(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"], "not-dict"], {}) + assert result == [] + + def test_non_dict_resource_def_skipped(self): + foreach_value = ["Name", ["A"], {"Res": "not-a-dict"}] + result = detect_foreach_dynamic_properties("Fn::ForEach::X", foreach_value, {}) + assert result == [] + + def test_non_string_resource_type_skipped(self): + foreach_value = ["Name", ["A"], {"Res": {"Type": 123, "Properties": {}}}] + result = detect_foreach_dynamic_properties("Fn::ForEach::X", foreach_value, {}) + assert result == [] + + def test_non_packageable_resource_type_skipped(self): + foreach_value = [ + "Name", + ["A"], + {"Res": {"Type": "AWS::DynamoDB::Table", "Properties": {"TableName": "${Name}"}}}, + ] + result = detect_foreach_dynamic_properties("Fn::ForEach::X", foreach_value, {}) + assert result == [] + + def test_non_dict_properties_skipped(self): + foreach_value = [ + "Name", + ["A"], + {"Res": {"Type": "AWS::Serverless::Function", "Properties": "not-dict"}}, + ] + result = detect_foreach_dynamic_properties("Fn::ForEach::X", foreach_value, {}) + assert result == [] + + def test_empty_collection_returns_empty(self): + foreach_value = [ + "Name", + {"Ref": "Missing"}, + {"Res": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "${Name}"}}}, + ] + result = detect_foreach_dynamic_properties("Fn::ForEach::X", foreach_value, {}) + assert result == [] + + def test_parameter_ref_collection_detected(self): + foreach_value = [ + "Name", + {"Ref": "ServiceNames"}, + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src/${Name}"}, + } + }, + ] + template = {"Parameters": {"ServiceNames": {"Type": "CommaDelimitedList", "Default": "A,B"}}} + result = detect_foreach_dynamic_properties("Fn::ForEach::Svc", foreach_value, template) + assert len(result) == 1 + assert result[0].collection_is_parameter_ref is True + assert result[0].collection_parameter_name == "ServiceNames" + + def test_lambda_function_dynamic_code(self): + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": "./src/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + result = detect_foreach_dynamic_properties("Fn::ForEach::Funcs", foreach_value, {}) + assert len(result) == 1 + assert result[0].property_name == "Code" + + def test_layer_dynamic_contenturi(self): + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "./layers/${Name}"}, + } + }, + ] + result = detect_foreach_dynamic_properties("Fn::ForEach::Layers", foreach_value, {}) + assert len(result) == 1 + assert result[0].property_name == "ContentUri" + + +class TestDetectDynamicArtifactProperties: + """Tests for detect_dynamic_artifact_properties.""" + + def test_detects_properties_in_resources(self): + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src/${Name}"}, + } + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + assert len(result) == 1 + + def test_no_foreach_returns_empty(self): + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + } + } + result = detect_dynamic_artifact_properties(template) + assert result == [] + + def test_non_dict_resources_returns_empty(self): + result = detect_dynamic_artifact_properties({"Resources": "not-a-dict"}) + assert result == [] + + def test_no_resources_returns_empty(self): + result = detect_dynamic_artifact_properties({"AWSTemplateFormatVersion": "2010-09-09"}) + assert result == [] + + def test_multiple_foreach_blocks(self): + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["A", "B"], + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src/${Name}"}, + } + }, + ], + "Fn::ForEach::Layers": [ + "Name", + ["X", "Y"], + { + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "./layers/${Name}"}, + } + }, + ], + } + } + result = detect_dynamic_artifact_properties(template) + assert len(result) == 2 + + +class TestExpandLanguageExtensionsEdgeCases: + """Tests for expand_language_extensions edge cases.""" + + def setup_method(self): + clear_expansion_cache() + + def teardown_method(self): + clear_expansion_cache() + + def test_oserror_on_getmtime_disables_caching(self): + template = {"Transform": "AWS::Serverless-2016-10-31", "Resources": {}} + result = expand_language_extensions(template, template_path="/nonexistent/path/template.yaml") + assert result.had_language_extensions is False + + def test_non_language_extension_template_cached_when_path_provided(self): + with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w") as f: + f.write("Resources: {}") + f.flush() + path = f.name + + try: + template = {"Resources": {}} + result1 = expand_language_extensions(template, template_path=path) + result2 = expand_language_extensions(template, template_path=path) + assert result1 is result2 # Same object from cache + assert result1.had_language_extensions is False + finally: + os.unlink(path) + + def test_non_invalid_template_exception_reraised(self): + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Test": [ + "Name", + ["A"], + { + "${Name}Res": { + "Type": "AWS::CloudFormation::WaitConditionHandle", + } + }, + ] + }, + } + with patch( + "samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli", + side_effect=RuntimeError("unexpected error"), + ): + with pytest.raises(RuntimeError, match="unexpected error"): + expand_language_extensions(template) + + def test_invalid_template_exception_converted(self): + from samcli.lib.cfn_language_extensions.exceptions import ( + InvalidTemplateException as LangExtInvalidTemplateException, + ) + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": {}, + } + with patch( + "samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli", + side_effect=LangExtInvalidTemplateException("bad template"), + ): + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + + with pytest.raises(InvalidSamDocumentException): + expand_language_extensions(template) + + +class TestCheckUsingLanguageExtensionEdgeCases: + """Tests for check_using_language_extension edge cases.""" + + def test_none_template(self): + assert check_using_language_extension(None) is False + + def test_no_transform_key(self): + assert check_using_language_extension({"Resources": {}}) is False + + def test_empty_transform(self): + assert check_using_language_extension({"Transform": ""}) is False + + def test_non_string_in_transform_list(self): + assert check_using_language_extension({"Transform": [123, "AWS::LanguageExtensions"]}) is True + + def test_non_string_only_in_transform_list(self): + assert check_using_language_extension({"Transform": [123, 456]}) is False + + def test_transform_list_without_language_extensions(self): + assert check_using_language_extension({"Transform": ["AWS::Serverless-2016-10-31"]}) is False + + def test_transform_none_value(self): + assert check_using_language_extension({"Transform": None}) is False + + +class TestLanguageExtensionResultDataclass: + """Tests for LanguageExtensionResult frozen dataclass.""" + + def test_default_values(self): + result = LanguageExtensionResult( + expanded_template={}, + original_template={}, + ) + assert result.dynamic_artifact_properties == [] + assert result.had_language_extensions is False + + def test_frozen(self): + result = LanguageExtensionResult( + expanded_template={}, + original_template={}, + ) + with pytest.raises(AttributeError): + result.had_language_extensions = True + + +class TestHashParams: + """Edge case tests for _hash_params.""" + + def test_non_string_values(self): + h1 = _hash_params({"key": 123}) + h2 = _hash_params({"key": 123}) + assert h1 == h2 + + def test_nested_dict_values_are_hashable(self): + """Nested dicts (e.g. intrinsic functions) should be hashable.""" + h1 = _hash_params({"key": {"Ref": "SomeParam"}}) + h2 = _hash_params({"key": {"Ref": "SomeParam"}}) + assert h1 == h2 + + def test_different_nested_values_produce_different_hash(self): + h1 = _hash_params({"key": {"Ref": "ParamA"}}) + h2 = _hash_params({"key": {"Ref": "ParamB"}}) + assert h1 != h2 diff --git a/tests/unit/lib/cfn_language_extensions/test_template_cache.py b/tests/unit/lib/cfn_language_extensions/test_template_cache.py new file mode 100644 index 00000000000..259fc859eb8 --- /dev/null +++ b/tests/unit/lib/cfn_language_extensions/test_template_cache.py @@ -0,0 +1,504 @@ +""" +Unit tests for Template-Level Cache (Tasks 36.1-36.2). + +This module covers: +- Task 36.1: Cache hit/miss behavior tests +- Task 36.2: Cache scoping and invalidation tests + +Requirements tested: + - 24.1: Cache hit returns same LanguageExtensionResult without re-expanding + - 24.2: Cache miss on different template path triggers re-expansion + - 24.3: Cache miss on different mtime triggers re-expansion + - 24.4: Cache miss on different parameter values triggers re-expansion + - 24.5: Cache is scoped to a single command invocation + - 24.6: Cache invalidation works for warm container file change events +""" + +import os +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from samcli.lib.cfn_language_extensions.sam_integration import ( + LanguageExtensionResult, + expand_language_extensions, + clear_expansion_cache, + _expansion_cache, + _hash_params, +) + + +def _make_language_extensions_template(resource_name="AlphaFunction"): + """Helper to create a minimal template with AWS::LanguageExtensions transform.""" + return { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "${Name}.handler", + "CodeUri": "./src", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + +def _make_expanded_template(): + """Helper to create a mock expanded template.""" + return { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Alpha.handler", + "CodeUri": "./src", + "Runtime": "python3.9", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "Beta.handler", + "CodeUri": "./src", + "Runtime": "python3.9", + }, + }, + }, + } + + +# ============================================================================= +# Task 36.1: Unit Tests for Cache Hit/Miss Behavior +# ============================================================================= + + +class TestCacheHitMissBehavior(TestCase): + """ + Unit tests for cache hit/miss behavior. + + Validates: Requirements 24.1, 24.2, 24.3, 24.4 + """ + + def setUp(self): + """Clear the expansion cache before each test.""" + clear_expansion_cache() + + def tearDown(self): + """Clear the expansion cache after each test.""" + clear_expansion_cache() + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_hit_returns_same_result_without_re_expanding(self, mock_getmtime, mock_process): + """ + Test that a cache hit returns the same LanguageExtensionResult without + calling process_template_for_sam_cli() again. + + Validates: Requirement 24.1 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + params = {"Param1": "Value1"} + + # First call — cache miss, should call process_template_for_sam_cli + result1 = expand_language_extensions(template, parameter_values=params, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + self.assertTrue(result1.had_language_extensions) + + # Second call — cache hit, should NOT call process_template_for_sam_cli again + result2 = expand_language_extensions(template, parameter_values=params, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) # Still 1, not 2 + + # Both results should be the exact same object + self.assertIs(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_miss_on_different_template_path(self, mock_getmtime, mock_process): + """ + Test that a different template_path causes a cache miss and re-expansion. + + Validates: Requirement 24.2 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + params = {"Param1": "Value1"} + + # First call with path A + result1 = expand_language_extensions(template, parameter_values=params, template_path="/tmp/templateA.yaml") + self.assertEqual(mock_process.call_count, 1) + + # Second call with path B — different path, should be a cache miss + result2 = expand_language_extensions(template, parameter_values=params, template_path="/tmp/templateB.yaml") + self.assertEqual(mock_process.call_count, 2) + + # Results should NOT be the same object (different cache entries) + self.assertIsNot(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_miss_on_different_mtime(self, mock_getmtime, mock_process): + """ + Test that a different file mtime causes a cache miss and re-expansion. + + Validates: Requirement 24.3 + """ + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + params = {"Param1": "Value1"} + + # First call with mtime 1000.0 + mock_getmtime.return_value = 1000.0 + result1 = expand_language_extensions(template, parameter_values=params, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + + # Second call with mtime 2000.0 — file was modified, should be a cache miss + mock_getmtime.return_value = 2000.0 + result2 = expand_language_extensions(template, parameter_values=params, template_path=template_path) + self.assertEqual(mock_process.call_count, 2) + + # Results should NOT be the same object + self.assertIsNot(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_miss_on_different_parameter_values(self, mock_getmtime, mock_process): + """ + Test that different parameter values cause a cache miss and re-expansion. + + Validates: Requirement 24.4 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + + # First call with params A + result1 = expand_language_extensions( + template, parameter_values={"Param1": "ValueA"}, template_path=template_path + ) + self.assertEqual(mock_process.call_count, 1) + + # Second call with params B — different params, should be a cache miss + result2 = expand_language_extensions( + template, parameter_values={"Param1": "ValueB"}, template_path=template_path + ) + self.assertEqual(mock_process.call_count, 2) + + # Results should NOT be the same object + self.assertIsNot(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_hit_with_none_params(self, mock_getmtime, mock_process): + """ + Test that cache hit works correctly when parameter_values is None. + + Validates: Requirement 24.1 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + + # First call with None params + result1 = expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + + # Second call with None params — should be a cache hit + result2 = expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + + self.assertIs(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + def test_no_caching_without_template_path(self, mock_process): + """ + Test that caching is not used when template_path is not provided. + + Validates: Requirement 24.1 (cache requires template_path) + """ + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + + # First call without template_path + result1 = expand_language_extensions(template, parameter_values=None, template_path=None) + self.assertEqual(mock_process.call_count, 1) + + # Second call without template_path — no caching, should re-expand + result2 = expand_language_extensions(template, parameter_values=None, template_path=None) + self.assertEqual(mock_process.call_count, 2) + + # Results should NOT be the same object + self.assertIsNot(result1, result2) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_hit_for_non_language_extensions_template(self, mock_getmtime, mock_process): + """ + Test that non-language-extensions templates are also cached when template_path is provided. + + Validates: Requirement 24.1 + """ + mock_getmtime.return_value = 1000.0 + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler", "Runtime": "python3.9"}, + } + }, + } + template_path = "/tmp/template.yaml" + + # First call — no language extensions, but should cache + result1 = expand_language_extensions(template, template_path=template_path) + self.assertFalse(result1.had_language_extensions) + mock_process.assert_not_called() + + # Second call — should be a cache hit + result2 = expand_language_extensions(template, template_path=template_path) + self.assertIs(result1, result2) + mock_process.assert_not_called() + + +# ============================================================================= +# Task 36.2: Unit Tests for Cache Scoping and Invalidation +# ============================================================================= + + +class TestCacheScopingAndInvalidation(TestCase): + """ + Unit tests for cache scoping and invalidation. + + Validates: Requirements 24.5, 24.6 + """ + + def setUp(self): + """Clear the expansion cache before each test.""" + clear_expansion_cache() + + def tearDown(self): + """Clear the expansion cache after each test.""" + clear_expansion_cache() + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_clear_expansion_cache_clears_all_entries(self, mock_getmtime, mock_process): + """ + Test that clear_expansion_cache() removes all cached entries. + + Validates: Requirement 24.5 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + + # Populate cache with multiple entries + expand_language_extensions(template, parameter_values={"P": "1"}, template_path="/tmp/a.yaml") + expand_language_extensions(template, parameter_values={"P": "2"}, template_path="/tmp/b.yaml") + self.assertEqual(mock_process.call_count, 2) + + # Verify cache has entries + self.assertGreater(len(_expansion_cache), 0) + + # Clear the cache + clear_expansion_cache() + + # Verify cache is empty + self.assertEqual(len(_expansion_cache), 0) + + # Subsequent calls should re-expand (cache miss) + expand_language_extensions(template, parameter_values={"P": "1"}, template_path="/tmp/a.yaml") + self.assertEqual(mock_process.call_count, 3) + + def test_cache_is_empty_at_start_of_command(self): + """ + Test that the cache is empty after clear_expansion_cache() is called, + simulating the start of a new CLI command invocation. + + Validates: Requirement 24.5 + """ + # Simulate what happens at the start of each CLI command: + # clear_expansion_cache() is called + clear_expansion_cache() + + # Cache should be empty + self.assertEqual(len(_expansion_cache), 0) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_cache_empty_after_simulated_command_boundary(self, mock_getmtime, mock_process): + """ + Test that cache does not leak between simulated command invocations. + + Validates: Requirement 24.5 + """ + mock_getmtime.return_value = 1000.0 + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + + # Simulate first command: populate cache + expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + + # Simulate command boundary: clear cache (as done at start of each CLI command) + clear_expansion_cache() + + # Simulate second command: should re-expand (cache was cleared) + expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 2) + + @patch( + "samcli.lib.providers.sam_function_provider.SamLocalStackProvider.get_stacks", + return_value=([], None), + ) + def test_warm_container_refresh_calls_clear_expansion_cache(self, mock_get_stacks): + """ + Test that RefreshableSamFunctionProvider._refresh_loaded_functions() + calls clear_expansion_cache() to invalidate the cache on file changes. + + Validates: Requirement 24.6 + """ + from samcli.lib.providers.sam_function_provider import RefreshableSamFunctionProvider + from samcli.lib.providers.provider import Stack + + # Create a minimal root stack + root_stack = Stack( + parent_stack_path="", + name="", + location="/tmp/template.yaml", + parameters={}, + template_dict={ + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": {}, + }, + metadata={}, + ) + + with patch.object(RefreshableSamFunctionProvider, "__init__", return_value=None): + provider = RefreshableSamFunctionProvider.__new__(RefreshableSamFunctionProvider) + # Set up minimal state needed for _refresh_loaded_functions + provider._stacks = [root_stack] + provider.parent_templates_paths = ["/tmp/template.yaml"] + provider._parameter_overrides = None + provider._global_parameter_overrides = None + provider.is_changed = True + provider._use_raw_codeuri = False + provider._ignore_code_extraction_warnings = False + provider._function_logical_ids = None + provider._observer = MagicMock() + + # Populate the cache with an entry + _expansion_cache[("/tmp/template.yaml", 1000.0, hash(()))] = LanguageExtensionResult( + expanded_template={}, + original_template={}, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + self.assertGreater(len(_expansion_cache), 0) + + # Call _refresh_loaded_functions which should clear the cache + provider._refresh_loaded_functions() + + # Cache should be cleared + self.assertEqual(len(_expansion_cache), 0) + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") + def test_warm_container_file_change_invalidation_flow(self, mock_getmtime, mock_process): + """ + Test the full warm container file change invalidation flow: + 1. Template is expanded and cached + 2. File changes (mtime changes) + 3. Cache is cleared (simulating warm container refresh) + 4. Template is re-expanded with new mtime + + Validates: Requirement 24.6 + """ + mock_process.return_value = _make_expanded_template() + + template = _make_language_extensions_template() + template_path = "/tmp/template.yaml" + + # Step 1: Initial expansion with mtime 1000.0 + mock_getmtime.return_value = 1000.0 + result1 = expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + + # Step 2: Same call — cache hit + result2 = expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 1) + self.assertIs(result1, result2) + + # Step 3: Simulate file change — clear cache (as RefreshableSamFunctionProvider does) + clear_expansion_cache() + + # Step 4: File has new mtime after modification + mock_getmtime.return_value = 2000.0 + result3 = expand_language_extensions(template, parameter_values=None, template_path=template_path) + self.assertEqual(mock_process.call_count, 2) + + # Result should be a new object (re-expanded) + self.assertIsNot(result1, result3) + + +class TestHashParams(TestCase): + """ + Unit tests for the _hash_params helper function used in cache key computation. + """ + + def test_none_params_returns_consistent_hash(self): + """Test that None parameter values produce a consistent hash.""" + self.assertEqual(_hash_params(None), _hash_params(None)) + + def test_empty_dict_returns_consistent_hash(self): + """Test that empty dict parameter values produce a consistent hash.""" + self.assertEqual(_hash_params({}), _hash_params({})) + + def test_same_params_return_same_hash(self): + """Test that identical parameter values produce the same hash.""" + params = {"Param1": "Value1", "Param2": "Value2"} + self.assertEqual(_hash_params(params), _hash_params(params)) + + def test_different_params_return_different_hash(self): + """Test that different parameter values produce different hashes.""" + params_a = {"Param1": "ValueA"} + params_b = {"Param1": "ValueB"} + self.assertNotEqual(_hash_params(params_a), _hash_params(params_b)) + + def test_param_order_does_not_affect_hash(self): + """Test that parameter order does not affect the hash (sorted internally).""" + params_a = {"B": "2", "A": "1"} + params_b = {"A": "1", "B": "2"} + self.assertEqual(_hash_params(params_a), _hash_params(params_b)) + + def test_none_and_empty_produce_same_hash(self): + """Test that None and empty dict produce the same hash (both falsy).""" + self.assertEqual(_hash_params(None), _hash_params({})) diff --git a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py index 22e58d78755..39d342764de 100644 --- a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py +++ b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py @@ -1093,6 +1093,7 @@ def load_test_data(self, template_path): ("inputs/test_methods_resource_resolution.json", "outputs/outputs_methods_resource_resolution.json"), ] ) + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_intrinsic_sample_inputs_outputs(self, input, output): input_template = self.load_test_data(input) symbol_resolver = IntrinsicsSymbolTable(template=input_template, logical_id_translator={}) diff --git a/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py b/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py index 449092b2fba..825d2636017 100644 --- a/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py +++ b/tests/unit/lib/intrinsic_resolver/test_intrinsics_symbol_table.py @@ -90,6 +90,7 @@ def test_default_type_resolver_function(self): self.assertEqual(result, "MyApi") + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_default_type_resolver_function_alias(self): template = { "Resources": { @@ -145,18 +146,22 @@ def test_basic_unknown_translated_string_translation(self): res = symbol_resolver.get_translation("item", "RootResourceId") self.assertEqual(res, None) + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_arn_resolver_default_service_name(self): res = IntrinsicsSymbolTable().arn_resolver("test") self.assertEqual(res, "arn:aws:lambda:us-east-1:123456789012:function:test") + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_arn_resolver_lambda(self): res = IntrinsicsSymbolTable().arn_resolver("test", service_name="lambda") self.assertEqual(res, "arn:aws:lambda:us-east-1:123456789012:function:test") + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_arn_resolver_sns(self): res = IntrinsicsSymbolTable().arn_resolver("test", service_name="sns") self.assertEqual(res, "arn:aws:sns:us-east-1:123456789012:test") + @patch.dict("os.environ", {"AWS_REGION": ""}, clear=False) def test_arn_resolver_lambda_with_function_name(self): template = {"Resources": {"LambdaFunction": {"Properties": {"FunctionName": "function-name-override"}}}} res = IntrinsicsSymbolTable(template=template).arn_resolver("LambdaFunction", service_name="lambda") diff --git a/tests/unit/lib/providers/test_sam_stack_provider_language_extensions.py b/tests/unit/lib/providers/test_sam_stack_provider_language_extensions.py new file mode 100644 index 00000000000..b317abbb772 --- /dev/null +++ b/tests/unit/lib/providers/test_sam_stack_provider_language_extensions.py @@ -0,0 +1,155 @@ +""" +Tests for language extensions integration in SamLocalStackProvider.get_stacks(). + +Verifies that get_stacks() calls expand_language_extensions() directly +and uses LanguageExtensionResult to populate Stack objects. +""" + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult + + +class TestGetStacksLanguageExtensions(TestCase): + """Tests that get_stacks() calls expand_language_extensions() directly.""" + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_calls_expand_language_extensions(self, mock_expand, mock_get_template): + """get_stacks() should call expand_language_extensions() directly.""" + from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A", "B"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + ] + }, + } + expanded = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + "BFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + }, + } + mock_get_template.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + stacks, _ = SamLocalStackProvider.get_stacks(template_file="template.yaml") + + mock_expand.assert_called_once() + # Verify the expanded template is used for the stack + self.assertEqual(stacks[0].template_dict, expanded) + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_stores_original_template_on_stack(self, mock_expand, mock_get_template): + """get_stacks() should store original_template on Stack when language extensions are present.""" + from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + ["A", "B"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + ] + }, + } + expanded = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + "BFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + }, + } + mock_get_template.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + stacks, _ = SamLocalStackProvider.get_stacks(template_file="template.yaml") + + # original_template_dict should be set when language extensions were present + self.assertEqual(stacks[0].original_template_dict, template) + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_no_original_template_without_language_extensions(self, mock_expand, mock_get_template): + """get_stacks() should not store original_template when no language extensions.""" + from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": {"MyFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + } + mock_get_template.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=template, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=False, + ) + + stacks, _ = SamLocalStackProvider.get_stacks(template_file="template.yaml") + + # original_template_dict should be None when no language extensions + self.assertIsNone(stacks[0].original_template_dict) + + @patch("samcli.lib.providers.sam_stack_provider.get_template_data") + @patch("samcli.lib.cfn_language_extensions.sam_integration.expand_language_extensions") + def test_get_stacks_passes_merged_params_with_pseudo_params(self, mock_expand, mock_get_template): + """get_stacks() should pass merged parameter overrides including pseudo-params.""" + from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + + template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Loop": [ + "Name", + {"Ref": "Names"}, + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}}, + ] + }, + } + expanded = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "AFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + "BFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "src/"}}, + }, + } + mock_get_template.return_value = template + mock_expand.return_value = LanguageExtensionResult( + expanded_template=expanded, + original_template=template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + SamLocalStackProvider.get_stacks( + template_file="template.yaml", + parameter_overrides={"Names": "A,B"}, + ) + + call_kwargs = mock_expand.call_args + param_values = call_kwargs[1]["parameter_values"] + # Should include user overrides + self.assertIn("Names", param_values) + self.assertEqual(param_values["Names"], "A,B") + # Should include pseudo-parameters + self.assertIn("AWS::Region", param_values) diff --git a/tests/unit/lib/samlib/test_wrapper.py b/tests/unit/lib/samlib/test_wrapper.py index 3f91ff5c4ef..327f8104f95 100644 --- a/tests/unit/lib/samlib/test_wrapper.py +++ b/tests/unit/lib/samlib/test_wrapper.py @@ -1,12 +1,17 @@ +import copy +from typing import Any, Dict, List from unittest import TestCase -from unittest.mock import patch, Mock +from unittest.mock import patch, MagicMock from parameterized import parameterized from samcli.lib.samlib.wrapper import SamTranslatorWrapper +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException -class TestLanguageExtensionsPatching(TestCase): +class TestLanguageExtensionsCheck(TestCase): + """Tests for _check_using_language_extension static method.""" + @parameterized.expand( [ ({"Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"]}, True), @@ -18,11 +23,1170 @@ class TestLanguageExtensionsPatching(TestCase): ({}, False), ] ) - def test_check_using_langauge_extension(self, template, expected): + def test_check_using_language_extension(self, template, expected): self.assertEqual(SamTranslatorWrapper._check_using_language_extension(template), expected) - @patch("samcli.lib.samlib.wrapper.SamResource") - def test_patch_language_extensions(self, patched_sam_resource): - wrapper = SamTranslatorWrapper({"Transform": "AWS::LanguageExtensions"}) - wrapper._patch_language_extensions() - self.assertEqual(patched_sam_resource.valid.__name__, "patched_func") + +class TestBuildPseudoParameters(TestCase): + """Tests for _build_pseudo_parameters in sam_integration module.""" + + def test_build_pseudo_parameters_with_all_values(self): + """Test building pseudo parameters when all values are present.""" + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + parameter_values = { + "AWS::Region": "us-east-1", + "AWS::AccountId": "123456789012", + "AWS::StackName": "my-stack", + "AWS::StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/guid", + "AWS::Partition": "aws", + "AWS::URLSuffix": "amazonaws.com", + "MyParam": "my-value", # Non-pseudo parameter + } + pseudo_params = _build_pseudo_parameters(parameter_values) + + self.assertIsNotNone(pseudo_params) + self.assertEqual(pseudo_params.region, "us-east-1") + self.assertEqual(pseudo_params.account_id, "123456789012") + self.assertEqual(pseudo_params.stack_name, "my-stack") + self.assertEqual(pseudo_params.stack_id, "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/guid") + self.assertEqual(pseudo_params.partition, "aws") + self.assertEqual(pseudo_params.url_suffix, "amazonaws.com") + + def test_build_pseudo_parameters_with_partial_values(self): + """Test building pseudo parameters when only some values are present.""" + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + parameter_values = { + "AWS::Region": "us-west-2", + "AWS::AccountId": "987654321098", + "MyParam": "my-value", + } + pseudo_params = _build_pseudo_parameters(parameter_values) + + self.assertIsNotNone(pseudo_params) + self.assertEqual(pseudo_params.region, "us-west-2") + self.assertEqual(pseudo_params.account_id, "987654321098") + self.assertIsNone(pseudo_params.stack_name) + self.assertIsNone(pseudo_params.stack_id) + self.assertIsNone(pseudo_params.partition) + self.assertIsNone(pseudo_params.url_suffix) + + def test_build_pseudo_parameters_with_no_pseudo_params(self): + """Test building pseudo parameters when no pseudo params are present.""" + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + parameter_values = { + "MyParam": "my-value", + "AnotherParam": "another-value", + } + pseudo_params = _build_pseudo_parameters(parameter_values) + + self.assertIsNone(pseudo_params) + + def test_build_pseudo_parameters_with_none_parameter_values(self): + """Test building pseudo parameters when parameter_values is None.""" + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + pseudo_params = _build_pseudo_parameters(None) + + self.assertIsNone(pseudo_params) + + def test_build_pseudo_parameters_with_empty_parameter_values(self): + """Test building pseudo parameters when parameter_values is empty.""" + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + pseudo_params = _build_pseudo_parameters({}) + + self.assertIsNone(pseudo_params) + + +class TestProcessLanguageExtensions(TestCase): + """Tests for expand_language_extensions in sam_integration module (formerly _process_language_extensions on wrapper).""" + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + def test_expand_language_extensions_success(self, mock_process): + """Test successful expansion of language extensions.""" + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + + input_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"FunctionName": {"Fn::Sub": "${Name}-function"}}, + } + }, + ] + }, + } + expected_output = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"FunctionName": "Alpha-function"}, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"FunctionName": "Beta-function"}, + }, + }, + } + mock_process.return_value = expected_output + + result = expand_language_extensions(input_template, parameter_values={"AWS::Region": "us-east-1"}) + + self.assertTrue(result.had_language_extensions) + self.assertEqual(result.expanded_template, expected_output) + mock_process.assert_called_once() + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + def test_expand_language_extensions_with_pseudo_params(self, mock_process): + """Test that pseudo parameters are passed correctly.""" + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + + template = {"Transform": "AWS::LanguageExtensions", "Resources": {}} + parameter_values = { + "AWS::Region": "us-east-1", + "AWS::AccountId": "123456789012", + "MyParam": "value", + } + mock_process.return_value = template + + result = expand_language_extensions(template, parameter_values=parameter_values) + + self.assertTrue(result.had_language_extensions) + # Verify process_template_for_sam_cli was called with correct arguments + call_args = mock_process.call_args + self.assertEqual(call_args.kwargs["parameter_values"], parameter_values) + pseudo_params = call_args.kwargs["pseudo_parameters"] + self.assertIsNotNone(pseudo_params) + self.assertEqual(pseudo_params.region, "us-east-1") + self.assertEqual(pseudo_params.account_id, "123456789012") + + @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") + def test_expand_language_extensions_error_handling(self, mock_process): + """Test that InvalidTemplateException is converted to InvalidSamDocumentException.""" + from samcli.lib.cfn_language_extensions import InvalidTemplateException as LangExtInvalidTemplateException + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + + template = {"Transform": "AWS::LanguageExtensions", "Resources": {}} + mock_process.side_effect = LangExtInvalidTemplateException("Invalid Fn::ForEach syntax") + + with self.assertRaises(InvalidSamDocumentException) as context: + expand_language_extensions(template) + + self.assertIn("Invalid Fn::ForEach syntax", str(context.exception)) + + +class TestRunPluginsWithLanguageExtensions(TestCase): + """Tests for run_plugins method — Phase 2 only (no language extensions processing).""" + + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_run_plugins_does_not_call_language_extensions(self, mock_parser_class): + """Test that run_plugins no longer calls _process_language_extensions (Phase 2 only).""" + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": {"MyFunction": {"Type": "AWS::Serverless::Function"}}, + } + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(template) + # run_plugins should not attempt Phase 1 expansion + wrapper.run_plugins() + + # Parser should still be called (Phase 2) + mock_parser.parse.assert_called_once() + + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_run_plugins_works_with_pre_expanded_template(self, mock_parser_class): + """Test that run_plugins works correctly with a pre-expanded template.""" + # This is what the template looks like AFTER expand_language_extensions() + expanded_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "index.handler", + "Runtime": "python3.9", + "CodeUri": "./src", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "index.handler", + "Runtime": "python3.9", + "CodeUri": "./src", + }, + }, + }, + } + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(expanded_template) + result = wrapper.run_plugins() + + # Verify the parser received the expanded template + mock_parser.parse.assert_called_once() + parsed_template = mock_parser.parse.call_args[0][0] + self.assertIn("AlphaFunction", parsed_template["Resources"]) + self.assertIn("BetaFunction", parsed_template["Resources"]) + + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_run_plugins_with_language_extension_result(self, mock_parser_class): + """Test that run_plugins works when LanguageExtensionResult is provided.""" + from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult + + expanded_template = { + "Resources": { + "AlphaFunction": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./src"}}, + }, + } + original_template = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha"], + {"${Name}Function": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./src"}}}, + ], + }, + } + le_result = LanguageExtensionResult( + expanded_template=expanded_template, + original_template=original_template, + dynamic_artifact_properties=[], + had_language_extensions=True, + ) + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(expanded_template, language_extension_result=le_result) + wrapper.run_plugins() + + # Verify original template comes from the result + preserved = wrapper.get_original_template() + self.assertIn("Fn::ForEach::Functions", preserved["Resources"]) + + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_run_plugins_without_language_extensions_template(self, mock_parser_class): + """Test that run_plugins works normally for templates without language extensions.""" + template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": {"MyFunction": {"Type": "AWS::Serverless::Function"}}, + } + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(template) + wrapper.run_plugins() + + mock_parser.parse.assert_called_once() + + +class TestSamTranslatorWrapperTemplate(TestCase): + """Tests for template property.""" + + def test_template_returns_deep_copy(self): + """Test that template property returns a deep copy.""" + original = {"Resources": {"MyResource": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "test"}}}} + wrapper = SamTranslatorWrapper(original) + + template_copy = wrapper.template + template_copy["Resources"]["MyResource"]["Properties"]["BucketName"] = "modified" + + # Original should be unchanged + self.assertEqual(original["Resources"]["MyResource"]["Properties"]["BucketName"], "test") + + +# ============================================================================= +# Tests for Transform Detection +# ============================================================================= + + +class TestTransformDetectionProperties(TestCase): + """ + Tests for transform detection. + + Feature: cfn-language-extensions-integration + """ + + @parameterized.expand( + [ + ("string_transform", "AWS::LanguageExtensions", "MyTopic", "AWS::SNS::Topic"), + ("string_extension_variant", "AWS::LanguageExtensions-extension", "MyQueue", "AWS::SQS::Queue"), + ( + "list_transform", + ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "MyFunc", + "AWS::Lambda::Function", + ), + ] + ) + def test_property_1_language_extensions_detected_by_check( + self, + name: str, + transform: Any, + resource_id: str, + resource_type: str, + ): + """ + Property 1: Language Extensions Detected by Transform Check + + *For any* template containing `AWS::LanguageExtensions` in its Transform field + (either as a string or in a list), `check_using_language_extension()` SHALL return True. + + **Validates: Requirements 1.1, 1.2** + """ + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + template: Dict[str, Any] = { + "Transform": transform, + "Resources": { + resource_id: { + "Type": resource_type, + "Properties": {}, + } + }, + } + + self.assertTrue(check_using_language_extension(template)) + + @parameterized.expand( + [ + ("no_transform", None, "MyTopic", "AWS::SNS::Topic"), + ("serverless_only", "AWS::Serverless-2016-10-31", "MyFunc", "AWS::Serverless::Function"), + ("list_without_lang_ext", ["AWS::Serverless-2016-10-31", "AWS::Include"], "MyBucket", "AWS::S3::Bucket"), + ] + ) + def test_property_2_language_extensions_not_detected_without_transform( + self, + name: str, + transform: Any, + resource_id: str, + resource_type: str, + ): + """ + Property 2: Language Extensions Not Detected Without Transform + + *For any* template that does NOT contain `AWS::LanguageExtensions` in its Transform field, + `check_using_language_extension()` SHALL return False. + + **Validates: Requirements 1.1, 1.2** + """ + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + template: Dict[str, Any] = { + "Resources": { + resource_id: { + "Type": resource_type, + "Properties": {}, + } + }, + } + + if transform is not None: + template["Transform"] = transform + + self.assertFalse(check_using_language_extension(template)) + + +# ============================================================================= +# Tests for Template Immutability +# ============================================================================= + + +class TestTemplateImmutabilityProperties(TestCase): + """ + Tests for template immutability in SamTranslatorWrapper. + + Feature: cfn-language-extensions-integration, Property 5: Template Immutability + """ + + @parameterized.expand( + [ + ( + "single_resource_with_lang_ext", + { + "Transform": "AWS::LanguageExtensions", + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": "test"}}}, + }, + ), + ( + "list_transform_with_lang_ext", + { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunc": {"Type": "AWS::Serverless::Function", "Properties": {"Runtime": "python3.9"}} + }, + }, + ), + ( + "multiple_resources_with_lang_ext", + { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "TopicA": {"Type": "AWS::SNS::Topic", "Properties": {}}, + "QueueB": {"Type": "AWS::SQS::Queue", "Properties": {"VisibilityTimeout": 30}}, + }, + }, + ), + ] + ) + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_property_5_template_immutability_with_language_extensions( + self, + name: str, + template: Dict[str, Any], + mock_parser_class: MagicMock, + ): + """ + Property 5: Template Immutability (with language extensions) + + *For any* template processed by `SamTranslatorWrapper`, the original template + dictionary passed to the constructor SHALL remain unchanged after `run_plugins()` + completes (whether successfully or with an error). + + **Validates: Requirements 1.5** + """ + # Create a deep copy of the original template to compare later + original_template = copy.deepcopy(template) + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + # Execute — run_plugins is Phase 2 only now + wrapper = SamTranslatorWrapper(template) + wrapper.run_plugins() + + # Verify the original template passed to constructor is unchanged + self.assertEqual(template, original_template) + + @parameterized.expand( + [ + ( + "single_resource_no_lang_ext", + { + "Resources": {"MyBucket": {"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "test"}}}, + }, + ), + ( + "multiple_resources_no_lang_ext", + { + "Resources": { + "TopicA": {"Type": "AWS::SNS::Topic", "Properties": {}}, + "TableB": {"Type": "AWS::DynamoDB::Table", "Properties": {"TableName": "items"}}, + }, + }, + ), + ] + ) + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_property_5_template_immutability_without_language_extensions( + self, + name: str, + template: Dict[str, Any], + mock_parser_class: MagicMock, + ): + """ + Property 5: Template Immutability (without language extensions) + + *For any* template processed by `SamTranslatorWrapper`, the original template + dictionary passed to the constructor SHALL remain unchanged after `run_plugins()` + completes (whether successfully or with an error). + + **Validates: Requirements 1.5** + """ + # Create a deep copy of the original template to compare later + original_template = copy.deepcopy(template) + + # Setup mocks + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + # Execute + wrapper = SamTranslatorWrapper(template) + wrapper.run_plugins() + + # Verify the original template passed to constructor is unchanged + self.assertEqual(template, original_template) + + @parameterized.expand( + [ + ( + "single_resource_error", + { + "Transform": "AWS::LanguageExtensions", + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {}}}, + }, + ), + ( + "list_transform_error", + { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunc": {"Type": "AWS::Lambda::Function", "Properties": {"Handler": "index.handler"}} + }, + }, + ), + ] + ) + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_property_5_template_immutability_on_error( + self, + name: str, + template: Dict[str, Any], + mock_parser_class: MagicMock, + ): + """ + Property 5: Template Immutability (on error) + + *For any* template processed by `SamTranslatorWrapper`, the original template + dictionary passed to the constructor SHALL remain unchanged after `run_plugins()` + completes with an error. + + **Validates: Requirements 1.5** + """ + from samtranslator.model.exceptions import InvalidDocumentException, InvalidTemplateException + + # Create a deep copy of the original template to compare later + original_template = copy.deepcopy(template) + + # Setup mocks - simulate a parser error (Phase 2 error) + mock_parser = MagicMock() + mock_parser.parse.side_effect = InvalidDocumentException([InvalidTemplateException("Test error")]) + mock_parser_class.return_value = mock_parser + + # Execute and expect exception + wrapper = SamTranslatorWrapper(template) + with self.assertRaises(InvalidSamDocumentException): + wrapper.run_plugins() + + # Verify the original template passed to constructor is unchanged + self.assertEqual(template, original_template) + + +# ============================================================================= +# Tests for Pseudo-Parameter Extraction +# ============================================================================= + + +class TestPseudoParameterExtractionProperties(TestCase): + """ + Tests for pseudo-parameter extraction. + + Feature: cfn-language-extensions-integration + """ + + @parameterized.expand( + [ + ( + "region_only", + {"AWS::Region": "us-east-1"}, + ), + ( + "region_and_account", + {"AWS::Region": "us-west-2", "AWS::AccountId": "123456789012"}, + ), + ( + "all_pseudo_params", + { + "AWS::Region": "eu-west-1", + "AWS::AccountId": "987654321098", + "AWS::StackName": "my-stack", + "AWS::StackId": "arn:aws:cloudformation:eu-west-1:987654321098:stack/my-stack/guid", + "AWS::Partition": "aws", + "AWS::URLSuffix": "amazonaws.com", + }, + ), + ] + ) + def test_property_6_pseudo_parameter_extraction_completeness( + self, + name: str, + parameter_values: Dict[str, Any], + ): + """ + Property 6: Pseudo-Parameter Extraction Completeness + + *For any* parameter_values dictionary containing one or more AWS pseudo-parameters + (`AWS::Region`, `AWS::AccountId`, `AWS::StackName`, `AWS::StackId`, `AWS::Partition`, + `AWS::URLSuffix`), the `_build_pseudo_parameters()` function SHALL return a + `PseudoParameterValues` object with all present pseudo-parameters correctly populated. + + **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6** + """ + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + result = _build_pseudo_parameters(parameter_values) + + # Result should NOT be None since we have at least one pseudo-parameter + self.assertIsNotNone(result, "Expected PseudoParameterValues but got None") + assert result is not None # For mypy + + # Verify each pseudo-parameter is correctly extracted if present in input + if "AWS::Region" in parameter_values: + self.assertEqual(result.region, parameter_values["AWS::Region"]) + else: + self.assertEqual(result.region, "") + + if "AWS::AccountId" in parameter_values: + self.assertEqual(result.account_id, parameter_values["AWS::AccountId"]) + else: + self.assertEqual(result.account_id, "") + + if "AWS::StackName" in parameter_values: + self.assertEqual(result.stack_name, parameter_values["AWS::StackName"]) + else: + self.assertIsNone(result.stack_name) + + if "AWS::StackId" in parameter_values: + self.assertEqual(result.stack_id, parameter_values["AWS::StackId"]) + else: + self.assertIsNone(result.stack_id) + + if "AWS::Partition" in parameter_values: + self.assertEqual(result.partition, parameter_values["AWS::Partition"]) + else: + self.assertIsNone(result.partition) + + if "AWS::URLSuffix" in parameter_values: + self.assertEqual(result.url_suffix, parameter_values["AWS::URLSuffix"]) + else: + self.assertIsNone(result.url_suffix) + + @parameterized.expand( + [ + ("none_input", None), + ("empty_dict", {}), + ("non_pseudo_only", {"MyParam": "value", "AnotherParam": "other"}), + ] + ) + def test_property_7_pseudo_parameter_extraction_returns_none_for_empty_input( + self, + name: str, + parameter_values: Any, + ): + """ + Property 7: Pseudo-Parameter Extraction Returns None for Empty Input + + *For any* parameter_values that is None, empty, or contains no AWS pseudo-parameters, + the `_build_pseudo_parameters()` function SHALL return None. + + **Validates: Requirements 2.7, 2.8** + """ + from samcli.lib.cfn_language_extensions.sam_integration import _build_pseudo_parameters + + result = _build_pseudo_parameters(parameter_values) + + self.assertIsNone( + result, + f"Expected None but got PseudoParameterValues for parameter_values: {parameter_values}", + ) + + +# ============================================================================= +# Tests for Original Template Preservation +# ============================================================================= + + +class TestOriginalTemplatePreservation(TestCase): + """ + Tests for original template preservation in SamTranslatorWrapper. + + Feature: cfn-language-extensions-integration + **Validates: Requirements 15.1, 15.2, 15.3** + """ + + def test_get_original_template_returns_deep_copy(self): + """Test that get_original_template() returns a deep copy of the original template.""" + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "${Name}.handler"}, + } + }, + ] + }, + } + wrapper = SamTranslatorWrapper(original) + + # Get the original template + result = wrapper.get_original_template() + + # Verify it contains the Fn::ForEach structure + self.assertIn("Fn::ForEach::Functions", result["Resources"]) + + # Modify the returned template + result["Resources"]["NewResource"] = {"Type": "AWS::S3::Bucket"} + + # Get original again and verify it's unchanged + result2 = wrapper.get_original_template() + self.assertNotIn("NewResource", result2["Resources"]) + self.assertIn("Fn::ForEach::Functions", result2["Resources"]) + + def test_get_original_template_returns_unexpanded_template(self): + """ + Test that get_original_template() returns the unexpanded template with Fn::ForEach intact. + + **Validates: Requirements 15.1** + """ + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "ServiceName", + ["Users", "Orders", "Products"], + { + "${ServiceName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "${ServiceName}.handler", + "Runtime": "python3.9", + "CodeUri": "./src", + }, + } + }, + ] + }, + } + wrapper = SamTranslatorWrapper(original) + + # Get the original template + result = wrapper.get_original_template() + + # Verify it returns the unexpanded template (Fn::ForEach intact) + self.assertIn("Fn::ForEach::Services", result["Resources"]) + # Verify expanded resources are NOT present + self.assertNotIn("UsersFunction", result["Resources"]) + self.assertNotIn("OrdersFunction", result["Resources"]) + self.assertNotIn("ProductsFunction", result["Resources"]) + # Verify the Fn::ForEach structure is complete + foreach_block = result["Resources"]["Fn::ForEach::Services"] + self.assertEqual(foreach_block[0], "ServiceName") # Loop variable + self.assertEqual(foreach_block[1], ["Users", "Orders", "Products"]) # Collection + self.assertIn("${ServiceName}Function", foreach_block[2]) # Output template + + def test_foreach_structure_preserved_with_all_elements(self): + """ + Test that Fn::ForEach structure is preserved with all its elements intact. + + **Validates: Requirements 15.3** + """ + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta", "Gamma"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": {"Fn::Sub": "${Name}.handler"}, + "Runtime": "python3.9", + "CodeUri": "./src", + "Environment": { + "Variables": { + "FUNCTION_NAME": {"Fn::Sub": "${Name}"}, + } + }, + }, + } + }, + ] + }, + } + wrapper = SamTranslatorWrapper(original) + + # Get the original template + result = wrapper.get_original_template() + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Functions", result["Resources"]) + foreach_block = result["Resources"]["Fn::ForEach::Functions"] + + # Verify all three elements are preserved + self.assertEqual(len(foreach_block), 3) + + # Element 1: Loop variable + self.assertEqual(foreach_block[0], "Name") + + # Element 2: Collection + self.assertEqual(foreach_block[1], ["Alpha", "Beta", "Gamma"]) + + # Element 3: Output template with resource definition + output_template = foreach_block[2] + self.assertIn("${Name}Function", output_template) + resource_def = output_template["${Name}Function"] + self.assertEqual(resource_def["Type"], "AWS::Serverless::Function") + self.assertEqual(resource_def["Properties"]["Handler"], {"Fn::Sub": "${Name}.handler"}) + self.assertEqual(resource_def["Properties"]["Runtime"], "python3.9") + self.assertEqual(resource_def["Properties"]["CodeUri"], "./src") + self.assertEqual( + resource_def["Properties"]["Environment"]["Variables"]["FUNCTION_NAME"], {"Fn::Sub": "${Name}"} + ) + + def test_multiple_foreach_blocks_preserved(self): + """ + Test that multiple Fn::ForEach blocks are all preserved in the original template. + + **Validates: Requirements 15.3** + """ + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "FuncName", + ["Alpha", "Beta"], + { + "${FuncName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler"}, + } + }, + ], + "Fn::ForEach::Tables": [ + "TableName", + ["Users", "Orders"], + { + "${TableName}Table": { + "Type": "AWS::DynamoDB::Table", + "Properties": {"TableName": {"Fn::Sub": "${TableName}"}}, + } + }, + ], + "StaticResource": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": "my-bucket"}, + }, + }, + } + wrapper = SamTranslatorWrapper(original) + + # Get the original template + result = wrapper.get_original_template() + + # Verify both Fn::ForEach blocks are preserved + self.assertIn("Fn::ForEach::Functions", result["Resources"]) + self.assertIn("Fn::ForEach::Tables", result["Resources"]) + # Verify static resource is also preserved + self.assertIn("StaticResource", result["Resources"]) + + # Verify expanded resources are NOT present + self.assertNotIn("AlphaFunction", result["Resources"]) + self.assertNotIn("BetaFunction", result["Resources"]) + self.assertNotIn("UsersTable", result["Resources"]) + self.assertNotIn("OrdersTable", result["Resources"]) + + # Verify Fn::ForEach::Functions structure + func_foreach = result["Resources"]["Fn::ForEach::Functions"] + self.assertEqual(func_foreach[0], "FuncName") + self.assertEqual(func_foreach[1], ["Alpha", "Beta"]) + + # Verify Fn::ForEach::Tables structure + table_foreach = result["Resources"]["Fn::ForEach::Tables"] + self.assertEqual(table_foreach[0], "TableName") + self.assertEqual(table_foreach[1], ["Users", "Orders"]) + + def test_original_template_unchanged_after_run_plugins(self): + """ + Test that original template is unchanged after run_plugins() processes it. + + **Validates: Requirements 15.2** + """ + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "${Name}.handler", + "Runtime": "python3.9", + "CodeUri": "./src", + }, + } + }, + ] + }, + } + import copy + + original_copy = copy.deepcopy(original) + + with patch("samcli.lib.samlib.wrapper._SamParserReimplemented") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(original) + + # Run plugins (Phase 2 only — no expansion) + wrapper.run_plugins() + + # Get original template - should still have Fn::ForEach + preserved = wrapper.get_original_template() + self.assertIn("Fn::ForEach::Functions", preserved["Resources"]) + + # Verify the entire original structure is preserved + self.assertEqual(preserved, original_copy) + + def test_foreach_structure_preserved_after_run_plugins(self): + """ + Test that Fn::ForEach structure is preserved in original template after run_plugins(). + + This test verifies that after run_plugins() processes the template (Phase 2 only), + the original template still contains the complete Fn::ForEach structure. + + **Validates: Requirements 15.2, 15.3** + """ + original = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": { + "Environment": {"Type": "String", "Default": "dev"}, + }, + "Resources": { + "Fn::ForEach::Services": [ + "ServiceName", + ["Auth", "Payment", "Notification"], + { + "${ServiceName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "${ServiceName}-${Environment}"}, + "Handler": {"Fn::Sub": "${ServiceName}.handler"}, + "Runtime": "python3.9", + "CodeUri": "./services", + "Environment": { + "Variables": { + "SERVICE_NAME": {"Fn::Sub": "${ServiceName}"}, + } + }, + }, + } + }, + ] + }, + } + + with patch("samcli.lib.samlib.wrapper._SamParserReimplemented") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(original) + + # Run plugins (Phase 2 only) + wrapper.run_plugins() + + # Get original template + preserved = wrapper.get_original_template() + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Services", preserved["Resources"]) + foreach_block = preserved["Resources"]["Fn::ForEach::Services"] + + # Verify all three elements of Fn::ForEach are intact + self.assertEqual(len(foreach_block), 3) + self.assertEqual(foreach_block[0], "ServiceName") # Loop variable + self.assertEqual(foreach_block[1], ["Auth", "Payment", "Notification"]) # Collection + + # Verify output template structure + output_template = foreach_block[2] + self.assertIn("${ServiceName}Function", output_template) + resource_def = output_template["${ServiceName}Function"] + self.assertEqual(resource_def["Type"], "AWS::Serverless::Function") + self.assertEqual(resource_def["Properties"]["FunctionName"], {"Fn::Sub": "${ServiceName}-${Environment}"}) + self.assertEqual(resource_def["Properties"]["Handler"], {"Fn::Sub": "${ServiceName}.handler"}) + + def test_original_template_preserved_on_error(self): + """Test that original template is preserved even when run_plugins fails.""" + from samtranslator.model.exceptions import InvalidDocumentException, InvalidTemplateException + + original = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "Fn::ForEach::Invalid": ["missing", "arguments"], + }, + } + + with patch("samcli.lib.samlib.wrapper._SamParserReimplemented") as mock_parser_class: + mock_parser = MagicMock() + mock_parser.parse.side_effect = InvalidDocumentException([InvalidTemplateException("Parse error")]) + mock_parser_class.return_value = mock_parser + + wrapper = SamTranslatorWrapper(original) + + # Run plugins should raise an exception (Phase 2 error) + with self.assertRaises(InvalidSamDocumentException): + wrapper.run_plugins() + + # Original template should still be preserved + preserved = wrapper.get_original_template() + self.assertIn("Fn::ForEach::Invalid", preserved["Resources"]) + + def test_original_template_independent_of_input_modifications(self): + """Test that original template is independent of modifications to the input dict.""" + original = { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "index.handler"}, + } + }, + } + + wrapper = SamTranslatorWrapper(original) + + # Modify the original dict after creating wrapper + original["Resources"]["NewResource"] = {"Type": "AWS::S3::Bucket"} + original["Resources"]["MyFunction"]["Properties"]["Handler"] = "modified.handler" + + # Get original template - should NOT have the modifications + preserved = wrapper.get_original_template() + self.assertNotIn("NewResource", preserved["Resources"]) + self.assertEqual(preserved["Resources"]["MyFunction"]["Properties"]["Handler"], "index.handler") + + +# ============================================================================= +# Tests for Original Template Preservation (Parameterized) +# ============================================================================= + + +class TestOriginalTemplatePreservationProperties(TestCase): + """ + Tests for original template preservation in SamTranslatorWrapper. + + Feature: cfn-language-extensions-integration + **Validates: Requirements 1.5, 3.1, 3.2, 3.3, 3.4** + """ + + @parameterized.expand( + [ + ( + "single_resource", + { + "Transform": "AWS::LanguageExtensions", + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": "test"}}}, + }, + ), + ( + "list_transform", + { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunc": {"Type": "AWS::Serverless::Function", "Properties": {"Runtime": "python3.9"}} + }, + }, + ), + ( + "multiple_resources", + { + "Transform": "AWS::LanguageExtensions", + "Resources": { + "TopicA": {"Type": "AWS::SNS::Topic", "Properties": {}}, + "QueueB": {"Type": "AWS::SQS::Queue", "Properties": {}}, + }, + }, + ), + ] + ) + @patch("samcli.lib.samlib.wrapper._SamParserReimplemented") + def test_property_3_original_template_preserved_for_output( + self, + name: str, + template: Dict[str, Any], + mock_parser_class: MagicMock, + ): + """ + Property 3: Original Template Preserved for Output + + *For any* template with `Fn::ForEach` constructs, after processing, + the `get_original_template()` method SHALL return a template that + preserves the original `Fn::ForEach` structure. + + **Validates: Requirements 3.1, 3.2, 3.3, 3.4** + """ + # Create a deep copy of the original template to compare later + original_template = copy.deepcopy(template) + + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + + # Execute — run_plugins is Phase 2 only + wrapper = SamTranslatorWrapper(template) + wrapper.run_plugins() + + # Verify get_original_template() returns the original structure + preserved = wrapper.get_original_template() + self.assertEqual(preserved, original_template) + + @parameterized.expand( + [ + ( + "single_resource", + { + "Transform": "AWS::LanguageExtensions", + "Resources": {"MyTopic": {"Type": "AWS::SNS::Topic", "Properties": {}}}, + }, + ), + ( + "list_transform", + { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunc": {"Type": "AWS::Serverless::Function", "Properties": {"Runtime": "python3.9"}} + }, + }, + ), + ] + ) + def test_get_original_template_returns_independent_copy( + self, + name: str, + template: Dict[str, Any], + ): + """ + Test that get_original_template() returns an independent deep copy. + + Modifying the returned template should not affect subsequent calls + to get_original_template(). + + **Validates: Requirements 1.5** + """ + original_template = copy.deepcopy(template) + + wrapper = SamTranslatorWrapper(template) + + # Get original template and modify it + result1 = wrapper.get_original_template() + result1["Resources"]["ModifiedResource"] = {"Type": "AWS::S3::Bucket"} + + # Get original template again + result2 = wrapper.get_original_template() + + # result2 should match the original, not the modified result1 + self.assertEqual(result2, original_template) + self.assertNotIn("ModifiedResource", result2["Resources"]) diff --git a/tests/unit/lib/samlib/test_wrapper_language_extensions.py b/tests/unit/lib/samlib/test_wrapper_language_extensions.py new file mode 100644 index 00000000000..a48226202b7 --- /dev/null +++ b/tests/unit/lib/samlib/test_wrapper_language_extensions.py @@ -0,0 +1,429 @@ +""" +Tests for language extensions detection functions (canonical implementations in sam_integration) +and SamTranslatorWrapper's get_dynamic_artifact_properties / _check_using_language_extension. + +After the Phase 1/Phase 2 separation, the canonical implementations of +detect_dynamic_artifact_properties, detect_foreach_dynamic_properties, +resolve_collection, resolve_parameter_collection, and contains_loop_variable +live in samcli.lib.cfn_language_extensions.sam_integration. The delegation +wrappers on SamTranslatorWrapper have been removed as dead code. +""" + +from unittest import TestCase +from unittest.mock import patch + +from samcli.lib.cfn_language_extensions.sam_integration import ( + contains_loop_variable, + detect_dynamic_artifact_properties, + detect_foreach_dynamic_properties, + resolve_collection, + resolve_parameter_collection, +) +from samcli.lib.samlib.wrapper import SamTranslatorWrapper + + +class TestDetectDynamicArtifactProperties(TestCase): + """Tests for detect_dynamic_artifact_properties.""" + + def test_empty_resources(self): + result = detect_dynamic_artifact_properties({"Resources": {}}) + self.assertEqual(result, []) + + def test_no_resources_key(self): + result = detect_dynamic_artifact_properties({}) + self.assertEqual(result, []) + + def test_non_dict_resources(self): + result = detect_dynamic_artifact_properties({"Resources": "invalid"}) + self.assertEqual(result, []) + + def test_regular_resources_no_foreach(self): + template = { + "Resources": { + "MyFunc": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(result, []) + + def test_foreach_with_dynamic_codeuri(self): + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./services/${Name}"}, + } + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].foreach_key, "Fn::ForEach::Services") + self.assertEqual(result[0].loop_name, "Services") + self.assertEqual(result[0].loop_variable, "Name") + self.assertEqual(result[0].collection, ["Users", "Orders"]) + self.assertEqual(result[0].resource_key, "${Name}Function") + self.assertEqual(result[0].property_name, "CodeUri") + self.assertEqual(result[0].property_value, "./services/${Name}") + + def test_foreach_with_static_codeuri_no_loop_var(self): + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./shared-src"}, + } + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(result, []) + + def test_foreach_with_non_packageable_resource(self): + template = { + "Resources": { + "Fn::ForEach::Topics": [ + "Name", + ["A", "B"], + { + "${Name}Topic": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": "${Name}"}, + } + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(result, []) + + def test_multiple_foreach_blocks(self): + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "FuncName", + ["Alpha", "Beta"], + { + "${FuncName}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./${FuncName}"}, + } + }, + ], + "Fn::ForEach::Layers": [ + "LayerName", + ["Common", "Utils"], + { + "${LayerName}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "./layers/${LayerName}"}, + } + }, + ], + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(len(result), 2) + names = {r.property_name for r in result} + self.assertEqual(names, {"CodeUri", "ContentUri"}) + + +class TestDetectForeachDynamicProperties(TestCase): + """Tests for detect_foreach_dynamic_properties.""" + + def test_invalid_foreach_not_list(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", "not a list", {}) + self.assertEqual(result, []) + + def test_invalid_foreach_wrong_length(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["only", "two"], {}) + self.assertEqual(result, []) + + def test_non_string_loop_variable(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", [123, ["A"], {}], {}) + self.assertEqual(result, []) + + def test_non_dict_output_template(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"], "not a dict"], {}) + self.assertEqual(result, []) + + def test_non_dict_resource_def_skipped(self): + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"], {"${Name}Func": "not a dict"}], {}) + self.assertEqual(result, []) + + def test_non_string_resource_type_skipped(self): + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + ["Name", ["A"], {"${Name}Func": {"Type": 123, "Properties": {}}}], + {}, + ) + self.assertEqual(result, []) + + def test_non_dict_properties_skipped(self): + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + ["Name", ["A"], {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": "bad"}}], + {}, + ) + self.assertEqual(result, []) + + def test_empty_collection_returns_empty(self): + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + [ + "Name", + {"Ref": "NonExistentParam"}, + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./${Name}"}}}, + ], + {}, + ) + self.assertEqual(result, []) + + def test_property_value_none_skipped(self): + """If a packageable property is not present (None), it should be skipped.""" + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + [ + "Name", + ["A"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"Handler": "main.handler"}}}, + ], + {}, + ) + self.assertEqual(result, []) + + def test_parameter_ref_collection(self): + template = { + "Parameters": {"FuncNames": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta,Gamma"}}, + "Resources": {}, + } + result = detect_foreach_dynamic_properties( + "Fn::ForEach::Funcs", + [ + "Name", + {"Ref": "FuncNames"}, + { + "${Name}Func": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./${Name}"}, + } + }, + ], + template, + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].collection, ["Alpha", "Beta", "Gamma"]) + + +class TestResolveCollection(TestCase): + """Tests for resolve_collection.""" + + def test_static_list(self): + result = resolve_collection(["A", "B", "C"], {}) + self.assertEqual(result, ["A", "B", "C"]) + + def test_static_list_with_none_items(self): + result = resolve_collection(["A", None, "C"], {}) + self.assertEqual(result, ["A", "C"]) + + def test_static_list_with_numeric_items(self): + result = resolve_collection([1, 2, 3], {}) + self.assertEqual(result, ["1", "2", "3"]) + + def test_ref_parameter(self): + template = { + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "X,Y"}}, + } + result = resolve_collection({"Ref": "Names"}, template) + self.assertEqual(result, ["X", "Y"]) + + def test_unsupported_intrinsic(self): + result = resolve_collection({"Fn::Split": [",", "a,b"]}, {}) + self.assertEqual(result, []) + + def test_string_value_returns_empty(self): + result = resolve_collection("not a list or dict", {}) + self.assertEqual(result, []) + + def test_integer_value_returns_empty(self): + result = resolve_collection(42, {}) + self.assertEqual(result, []) + + +class TestResolveParameterCollection(TestCase): + """Tests for resolve_parameter_collection.""" + + def test_from_parameter_overrides_list(self): + result = resolve_parameter_collection("Names", {}, parameter_values={"Names": ["A", "B"]}) + self.assertEqual(result, ["A", "B"]) + + def test_from_parameter_overrides_comma_string(self): + result = resolve_parameter_collection("Names", {}, parameter_values={"Names": "Alpha, Beta, Gamma"}) + self.assertEqual(result, ["Alpha", "Beta", "Gamma"]) + + def test_from_template_default_list(self): + template = { + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": ["X", "Y"]}}, + } + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, ["X", "Y"]) + + def test_from_template_default_comma_string(self): + template = { + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Foo,Bar"}}, + } + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, ["Foo", "Bar"]) + + def test_parameter_not_found(self): + result = resolve_parameter_collection("Missing", {}) + self.assertEqual(result, []) + + def test_parameter_overrides_take_precedence(self): + template = { + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Default1,Default2"}}, + } + result = resolve_parameter_collection("Names", template, parameter_values={"Names": "Override1,Override2"}) + self.assertEqual(result, ["Override1", "Override2"]) + + def test_non_dict_param_def_returns_empty(self): + template = {"Parameters": {"Names": "not a dict"}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, []) + + def test_no_default_in_param_def_returns_empty(self): + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList"}}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, []) + + def test_no_parameters_section(self): + result = resolve_parameter_collection("Names", {"Resources": {}}) + self.assertEqual(result, []) + + +class TestContainsLoopVariable(TestCase): + """Tests for contains_loop_variable.""" + + def test_string_with_variable(self): + self.assertTrue(contains_loop_variable("./src/${Name}", "Name")) + + def test_string_without_variable(self): + self.assertFalse(contains_loop_variable("./src/static", "Name")) + + def test_string_partial_match_not_detected(self): + self.assertFalse(contains_loop_variable("./src/${NameExtra}", "Name")) + + def test_dict_with_fn_sub_string(self): + value = {"Fn::Sub": "./services/${Name}/code"} + self.assertTrue(contains_loop_variable(value, "Name")) + + def test_dict_with_fn_sub_list(self): + value = {"Fn::Sub": ["./services/${Name}/code", {"Name": "test"}]} + self.assertTrue(contains_loop_variable(value, "Name")) + + def test_dict_with_fn_sub_no_match(self): + value = {"Fn::Sub": "./services/static/code"} + self.assertFalse(contains_loop_variable(value, "Name")) + + def test_nested_dict(self): + value = {"Fn::Join": ["/", ["prefix", "${Name}"]]} + self.assertTrue(contains_loop_variable(value, "Name")) + + def test_list_with_variable(self): + value = ["static", "${Name}", "more"] + self.assertTrue(contains_loop_variable(value, "Name")) + + def test_list_without_variable(self): + value = ["static", "no-var", "more"] + self.assertFalse(contains_loop_variable(value, "Name")) + + def test_integer_value(self): + self.assertFalse(contains_loop_variable(42, "Name")) + + def test_none_value(self): + self.assertFalse(contains_loop_variable(None, "Name")) + + def test_bool_value(self): + self.assertFalse(contains_loop_variable(True, "Name")) + + def test_fn_sub_list_empty(self): + value = {"Fn::Sub": []} + self.assertFalse(contains_loop_variable(value, "Name")) + + +class TestGetDynamicArtifactProperties(TestCase): + """Tests for SamTranslatorWrapper.get_dynamic_artifact_properties.""" + + def test_returns_detected_properties_via_language_extension_result(self): + """When language_extension_result is provided, dynamic properties come from it.""" + from samcli.lib.cfn_language_extensions.sam_integration import LanguageExtensionResult + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + dynamic_props = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Funcs", + loop_name="Funcs", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + ] + result = LanguageExtensionResult( + expanded_template={"Resources": {}}, + original_template={"Resources": {}}, + dynamic_artifact_properties=dynamic_props, + had_language_extensions=True, + ) + with patch("samcli.lib.samlib.wrapper.SamTemplateValidator"): + wrapper = SamTranslatorWrapper({"Resources": {}}, language_extension_result=result) + props = wrapper.get_dynamic_artifact_properties() + self.assertEqual(len(props), 1) + self.assertEqual(props[0].loop_name, "Funcs") + + def test_returns_empty_when_no_foreach(self): + template = {"Resources": {"MyFunc": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./src"}}}} + with patch("samcli.lib.samlib.wrapper.SamTemplateValidator"): + wrapper = SamTranslatorWrapper(template) + props = wrapper.get_dynamic_artifact_properties() + self.assertEqual(props, []) + + +class TestCheckUsingLanguageExtension(TestCase): + """Additional edge case tests for _check_using_language_extension.""" + + def test_none_template(self): + self.assertFalse(SamTranslatorWrapper._check_using_language_extension(None)) + + def test_no_transform_key(self): + self.assertFalse(SamTranslatorWrapper._check_using_language_extension({"Resources": {}})) + + def test_empty_transform(self): + self.assertFalse(SamTranslatorWrapper._check_using_language_extension({"Transform": ""})) + + def test_list_with_non_string_entries(self): + self.assertFalse( + SamTranslatorWrapper._check_using_language_extension({"Transform": [{"Name": "AWS::Include"}, 42]}) + ) + + def test_list_with_language_extensions(self): + self.assertTrue( + SamTranslatorWrapper._check_using_language_extension( + {"Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"]} + ) + ) From e65826fd2b37680d7e4739a81bffad22239e0306 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 13 Feb 2026 23:38:07 +0000 Subject: [PATCH 15/21] feat: add language extensions support to sam build - _get_template_for_output() preserves Fn::ForEach in build output - _update_foreach_artifact_paths() generates Mappings for dynamic artifact properties with per-function build paths - Recursive nested Fn::ForEach support - ForEach-aware path resolution skips Docker image URIs Test templates: static CodeUri, dynamic CodeUri, parameter collections, nested stacks, nested ForEach, dynamic ImageUri, depth validation. --- samcli/commands/build/build_context.py | 317 ++++- samcli/lib/build/app_builder.py | 2 +- .../test_build_cmd_language_extensions.py | 1234 +++++++++++++++++ .../Alpha/__init__.py | 1 + .../Alpha/main.py | 12 + .../Beta/__init__.py | 1 + .../Beta/main.py | 12 + .../samconfig.toml | 13 + .../template.yaml | 38 + .../template.yaml | 38 + .../src/__init__.py | 1 + .../src/main.py | 146 ++ .../src/requirements.txt | 1 + .../template.yaml | 252 ++++ .../services/Orders/main.py | 5 + .../services/Users/main.py | 5 + .../template.yaml | 38 + .../template.yaml | 33 + .../template.yaml | 30 + .../language-extensions-nested/child.yaml | 61 + .../language-extensions-nested/parent.yaml | 54 + .../src/__init__.py | 1 + .../language-extensions-nested/src/main.py | 25 + .../src/__init__.py | 1 + .../src/main.py | 29 + .../src/requirements.txt | 1 + .../template.yaml | 52 + .../buildcmd/language-extensions.yaml | 101 +- .../testdata/buildcmd/samconfig.toml | 47 + .../commands/buildcmd/test_build_context.py | 448 ++++++ .../test_build_context_language_extensions.py | 91 ++ 31 files changed, 3081 insertions(+), 9 deletions(-) create mode 100644 tests/integration/buildcmd/test_build_cmd_language_extensions.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/__init__.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/__init__.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/samconfig.toml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-dynamic-imageuri/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/__init__.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/requirements.txt create mode 100644 tests/integration/testdata/buildcmd/language-extensions-findinmap-default/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested-foreach-invalid/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested-foreach-valid/template.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested/child.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested/parent.yaml create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested/src/__init__.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-nested/src/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-param-collection/src/__init__.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-param-collection/src/main.py create mode 100644 tests/integration/testdata/buildcmd/language-extensions-param-collection/src/requirements.txt create mode 100644 tests/integration/testdata/buildcmd/language-extensions-param-collection/template.yaml create mode 100644 tests/integration/testdata/buildcmd/samconfig.toml create mode 100644 tests/unit/commands/buildcmd/test_build_context_language_extensions.py diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 8ca8ebb1333..a5ca6974223 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -6,13 +6,14 @@ import os import pathlib import shutil -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import click from samcli.commands._utils.constants import DEFAULT_BUILD_DIR from samcli.commands._utils.experimental import ExperimentalFlag, prompt_experimental from samcli.commands._utils.template import ( + FOREACH_REQUIRED_ELEMENTS, get_template_data, move_template, ) @@ -33,6 +34,7 @@ InvalidBuildGraphException, ) from samcli.lib.build.workflow_config import UnsupportedRuntimeException +from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable, substitute_loop_variable from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.providers.provider import LayerVersion, ResourcesToBuildCollector, Stack from samcli.lib.providers.sam_api_provider import SamApiProvider @@ -400,7 +402,318 @@ def _handle_build_post_processing(self, builder: ApplicationBuilder, build_resul if esbuild_manager.esbuild_configured(): modified_template = esbuild_manager.handle_template_post_processing() - move_template(stack.location, output_template_path, modified_template) + # Determine which template to write to disk + # If the stack has an original template (with Fn::ForEach intact), use it + # Otherwise, use the modified (expanded) template + template_to_write = self._get_template_for_output(stack, modified_template, artifacts) + + move_template(stack.location, output_template_path, template_to_write) + + def _get_template_for_output(self, stack: Stack, modified_template: Dict, artifacts: Dict[str, str]) -> Dict: + """ + Get the template to write to the build output directory. + + For templates with language extensions (Fn::ForEach), we preserve the original + template structure and update artifact paths within the Fn::ForEach constructs. + This ensures CloudFormation can process the AWS::LanguageExtensions transform + server-side. + + Parameters + ---------- + stack : Stack + The stack being processed + modified_template : Dict + The expanded template with updated artifact paths + artifacts : Dict[str, str] + Map of resource full paths to their built artifact locations + + Returns + ------- + Dict + The template to write to disk + """ + import copy + + # If no original template, use the modified (expanded) template + # Check if original_template_dict exists and is a dict (not a Mock or other type) + original_template_dict = getattr(stack, "original_template_dict", None) + if not isinstance(original_template_dict, dict): + return modified_template + + # Use the original template (with Fn::ForEach intact) + # We need to update artifact paths in the Fn::ForEach constructs + original_template = copy.deepcopy(original_template_dict) + + # Update artifact paths in the original template + self._update_original_template_paths(original_template, modified_template, stack) + + return original_template + + def _update_original_template_paths(self, original_template: Dict, modified_template: Dict, stack: Stack) -> None: + """ + Update artifact paths in the original template based on the modified template. + + This method handles Fn::ForEach constructs by finding the corresponding + artifact paths from the expanded template and updating the original template. + For dynamic artifact properties, it generates Mappings sections. + + Parameters + ---------- + original_template : Dict + The original template with Fn::ForEach constructs (will be modified in place) + modified_template : Dict + The expanded template with updated artifact paths + stack : Stack + The stack being processed + """ + import pathlib + + original_dir = pathlib.Path(stack.location).parent.resolve() + + # Get the resources section from both templates + original_resources = original_template.get("Resources", {}) + modified_resources = modified_template.get("Resources", {}) + + # Collect all generated Mappings from dynamic artifact properties + all_generated_mappings: Dict[str, Dict[str, Dict[str, str]]] = {} + + # Process each resource in the original template + for resource_key, resource_value in original_resources.items(): + # Check if this is a Fn::ForEach construct + if resource_key.startswith("Fn::ForEach::"): + generated_mappings = self._update_foreach_artifact_paths( + resource_key, resource_value, modified_resources, original_dir + ) + all_generated_mappings.update(generated_mappings) + elif isinstance(resource_value, dict) and resource_key in modified_resources: + # Regular resource - copy updated paths from modified template + modified_resource = modified_resources.get(resource_key, {}) + self._copy_artifact_paths(resource_value, modified_resource) + + # Merge generated Mappings into the template + if all_generated_mappings: + if "Mappings" not in original_template: + original_template["Mappings"] = {} + original_template["Mappings"].update(all_generated_mappings) + + def _update_foreach_artifact_paths( + self, + foreach_key: str, + foreach_value: list, + modified_resources: Dict, + original_dir, + outer_context: Optional[List[Tuple[str, List[str]]]] = None, + ) -> Dict[str, Dict[str, Dict[str, str]]]: + """ + Update artifact paths in a Fn::ForEach construct. + + Recurses into nested Fn::ForEach blocks, passing outer loop context so that + expanded resource names can be fully resolved. + + Parameters + ---------- + foreach_key : str + The Fn::ForEach key (e.g., "Fn::ForEach::Functions") + foreach_value : list + The Fn::ForEach value [loop_var, collection, body] + modified_resources : Dict + The expanded resources with updated artifact paths + original_dir : pathlib.Path + The directory containing the original template + outer_context : list of tuples, optional + Enclosing loop variables and their collections for nested ForEach. + + Returns + ------- + Dict[str, Dict[str, Dict[str, str]]] + Generated Mappings section for dynamic artifact properties (empty dict if none) + """ + from samcli.lib.samlib.wrapper import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES + + generated_mappings: Dict[str, Dict[str, Dict[str, str]]] = {} + + if outer_context is None: + outer_context = [] + + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + return generated_mappings + + loop_variable = foreach_value[0] + collection = foreach_value[1] + body = foreach_value[2] + + if not isinstance(loop_variable, str) or not isinstance(body, dict): + return generated_mappings + + collection_values: List[str] = [] + if isinstance(collection, list): + collection_values = [str(item) for item in collection if item is not None] + + loop_name = foreach_key.replace("Fn::ForEach::", "") + current_outer_context = outer_context + [(loop_variable, collection_values)] + + for resource_template_key, resource_template in body.items(): + if isinstance(resource_template_key, str) and resource_template_key.startswith("Fn::ForEach::"): + nested_mappings = self._update_foreach_artifact_paths( + resource_template_key, + resource_template, + modified_resources, + original_dir, + outer_context=current_outer_context, + ) + generated_mappings.update(nested_mappings) + continue + + if not isinstance(resource_template, dict): + continue + + resource_type = resource_template.get("Type", "") + properties = resource_template.get("Properties", {}) + if not isinstance(properties, dict): + continue + + for prop_name in PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type, []): + prop_value = properties.get(prop_name) + if prop_value is None: + continue + + if contains_loop_variable(prop_value, loop_variable) and collection_values: + mapping_entries = self._collect_dynamic_mapping_entries( + resource_template_key, + prop_name, + loop_variable, + collection_values, + modified_resources, + outer_context, + ) + if mapping_entries: + mapping_name = f"SAM{prop_name}{loop_name}" + generated_mappings[mapping_name] = mapping_entries + properties[prop_name] = {"Fn::FindInMap": [mapping_name, {"Ref": loop_variable}, prop_name]} + else: + self._copy_static_artifact_property(properties, prop_name, resource_type, modified_resources) + + return generated_mappings + + def _collect_dynamic_mapping_entries( + self, + resource_template_key: str, + prop_name: str, + loop_variable: str, + collection_values: List[str], + modified_resources: Dict, + outer_context: List[Tuple[str, List[str]]], + ) -> Dict[str, Dict[str, str]]: + """ + Collect Mapping entries for a dynamic artifact property by looking up + expanded resources in modified_resources. + + For nested ForEach, enumerates all outer value combinations to find + the fully-expanded resource name. + """ + mapping_entries: Dict[str, Dict[str, str]] = {} + + for coll_value in collection_values: + if outer_context: + self._collect_nested_mapping_entry( + resource_template_key, + prop_name, + loop_variable, + coll_value, + modified_resources, + outer_context, + mapping_entries, + ) + else: + expanded_key = substitute_loop_variable(resource_template_key, loop_variable, coll_value) + artifact_value = self._get_artifact_value(modified_resources, expanded_key, prop_name) + if artifact_value is not None: + mapping_entries[coll_value] = {prop_name: artifact_value} + + return mapping_entries + + def _collect_nested_mapping_entry( + self, + resource_template_key: str, + prop_name: str, + loop_variable: str, + coll_value: str, + modified_resources: Dict, + outer_context: List[Tuple[str, List[str]]], + mapping_entries: Dict[str, Dict[str, str]], + ) -> None: + """Enumerate outer value combinations to find expanded resource for a nested ForEach.""" + import itertools + + outer_collections = [oc[1] for oc in outer_context] + outer_vars = [oc[0] for oc in outer_context] + + for outer_combo in itertools.product(*outer_collections): + expanded_key = resource_template_key + for ovar, oval in zip(outer_vars, outer_combo): + expanded_key = substitute_loop_variable(expanded_key, ovar, oval) + expanded_key = substitute_loop_variable(expanded_key, loop_variable, coll_value) + + artifact_value = self._get_artifact_value(modified_resources, expanded_key, prop_name) + if artifact_value is not None and coll_value not in mapping_entries: + mapping_entries[coll_value] = {prop_name: artifact_value} + + @staticmethod + def _get_artifact_value(modified_resources: Dict, expanded_key: str, prop_name: str) -> Optional[Any]: + """Extract an artifact property value from an expanded resource, or return None.""" + modified_resource = modified_resources.get(expanded_key, {}) + if not isinstance(modified_resource, dict): + return None + modified_props = modified_resource.get("Properties", {}) + if not isinstance(modified_props, dict): + return None + return modified_props.get(prop_name) + + @staticmethod + def _copy_static_artifact_property( + properties: Dict, + prop_name: str, + resource_type: str, + modified_resources: Dict, + ) -> None: + """Copy a static artifact property from the first matching expanded resource.""" + for modified_resource in modified_resources.values(): + if not isinstance(modified_resource, dict): + continue + if modified_resource.get("Type", "") != resource_type: + continue + modified_props = modified_resource.get("Properties", {}) + if prop_name in modified_props: + properties[prop_name] = modified_props[prop_name] + break + + def _copy_artifact_paths(self, original_resource: Dict, modified_resource: Dict) -> None: + """ + Copy artifact paths from modified resource to original resource. + + Uses PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES to determine which + properties to copy, avoiding a hardcoded elif chain. + + Parameters + ---------- + original_resource : Dict + The original resource (will be modified in place) + modified_resource : Dict + The modified resource with updated artifact paths + """ + from samcli.lib.samlib.wrapper import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES + + original_props = original_resource.get("Properties", {}) + modified_props = modified_resource.get("Properties", {}) + resource_type = original_resource.get("Type", "") + + prop_names = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type) + if not prop_names: + return + + for prop_name in prop_names: + if prop_name in modified_props: + original_props[prop_name] = modified_props[prop_name] def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_default_build_dir: bool) -> str: """ diff --git a/samcli/lib/build/app_builder.py b/samcli/lib/build/app_builder.py index 00e2eb075f2..62da04a80fa 100644 --- a/samcli/lib/build/app_builder.py +++ b/samcli/lib/build/app_builder.py @@ -370,7 +370,7 @@ def update_template( if has_build_artifact: ApplicationBuilder._update_built_resource( - built_artifacts[full_path], properties, resource_type, store_path + built_artifacts[full_path], properties, resource_type or "", store_path ) if is_stack: diff --git a/tests/integration/buildcmd/test_build_cmd_language_extensions.py b/tests/integration/buildcmd/test_build_cmd_language_extensions.py new file mode 100644 index 00000000000..685e7b7c496 --- /dev/null +++ b/tests/integration/buildcmd/test_build_cmd_language_extensions.py @@ -0,0 +1,1234 @@ +""" +Integration tests for sam build with CloudFormation Language Extensions. + +These tests verify that sam build correctly processes templates using +AWS::LanguageExtensions transform, including Fn::ForEach, Fn::ToJsonString, +Fn::Length intrinsic functions, and conditional DeletionPolicy/UpdateReplacePolicy. + +Requirements tested: +- 3.1: sam build expands Fn::ForEach and builds each generated function +- 3.3: sam build creates build artifacts for all expanded functions in .aws-sam/build/ +- 12.1: Integration tests verify sam build successfully builds functions generated by Fn::ForEach +""" + +import logging +import os +import sys +from pathlib import Path + +import pytest +import yaml + +from tests.integration.buildcmd.build_integ_base import BuildIntegBase +from tests.testing_utils import run_command + +LOG = logging.getLogger(__name__) + + +@pytest.mark.python +class TestBuildCommand_LanguageExtensions(BuildIntegBase): + """ + Integration tests for sam build with CloudFormation Language Extensions. + + Tests that sam build correctly processes templates with: + - Fn::ForEach generating multiple Lambda functions + - Fn::ToJsonString in environment variables + - Fn::Length for array operations + - Conditional DeletionPolicy and UpdateReplacePolicy using Fn::If + + Validates Requirements: 3.1, 3.3, 12.1 + """ + + template = "language-extensions.yaml" + + # Expected files in the build artifact for Python functions + EXPECTED_FILES_PROJECT_MANIFEST = { + "__init__.py", + "main.py", + } + + # Functions generated by Fn::ForEach + FOREACH_GENERATED_FUNCTIONS = ["AlphaFunction", "BetaFunction"] + + # Regular function with Fn::ToJsonString + CONFIG_FUNCTION = "ConfigFunction" + + def _get_python_version(self): + """Get the current Python version in the format expected by Lambda runtimes.""" + return f"python{sys.version_info.major}.{sys.version_info.minor}" + + def test_build_with_foreach_template(self): + """ + Test that sam build succeeds with a template using Fn::ForEach. + + This test verifies: + 1. Build succeeds with language extensions template (exit code 0) + 2. Expanded functions (AlphaFunction, BetaFunction) are built + 3. Build artifacts exist in .aws-sam/build/ for all functions + 4. ConfigFunction with Fn::ToJsonString is also built + 5. Conditional DeletionPolicy/UpdateReplacePolicy are resolved + + Validates Requirements: 3.1, 3.3, 12.1 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with runtime override + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build with language extensions template using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify expanded functions from Fn::ForEach are built + # Requirement 3.1: Fn::ForEach is expanded and each generated function is built + for function_name in self.FOREACH_GENERATED_FUNCTIONS: + self._verify_function_built(function_name) + + # Verify ConfigFunction with Fn::ToJsonString is built + self._verify_function_built(self.CONFIG_FUNCTION) + + # Verify conditional DeletionPolicy/UpdateReplacePolicy are resolved in the built template + self._verify_conditional_policies_resolved() + + LOG.info("Successfully verified all functions were built") + + def test_build_with_foreach_dynamic_codeuri(self): + """ + Test that sam build succeeds when Fn::ForEach uses dynamic CodeUri with ${FunctionName}. + + This test verifies: + 1. Build succeeds when CodeUri contains ${FunctionName} variable + 2. Each expanded function (AlphaFunction, BetaFunction) uses its own source directory + 3. Build artifacts are created correctly for each function + + Template structure: + - AlphaFunction -> CodeUri: Alpha/ + - BetaFunction -> CodeUri: Beta/ + + Validates Requirements: 3.1, 3.3, 12.1 + """ + # Use the dynamic CodeUri template + self.template_path = str(Path(self.test_data_path, "language-extensions-dynamic-codeuri", "template.yaml")) + + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with runtime override + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build with dynamic CodeUri template using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify expanded functions from Fn::ForEach are built + # Each function should have its own source from its respective directory + for function_name in self.FOREACH_GENERATED_FUNCTIONS: + self._verify_function_built(function_name) + + LOG.info("Successfully verified dynamic CodeUri functions were built") + + def test_build_dynamic_codeuri_generates_mappings(self): + """ + Test that sam build with dynamic CodeUri generates Mappings in the output template. + + This test verifies: + 1. Build succeeds with dynamic CodeUri template + 2. The built template contains a Mappings section with SAMCodeUriFunctions + 3. CodeUri is replaced with Fn::FindInMap referencing the Mappings + 4. Each collection value maps to its correct build artifact directory + 5. Fn::ForEach structure is preserved in the output + + Validates Requirements: 6.6, 16.6 + """ + # Use the dynamic CodeUri template + self.template_path = str(Path(self.test_data_path, "language-extensions-dynamic-codeuri", "template.yaml")) + + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + cmdlist = self.get_command_list(parameter_overrides=overrides) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Read the built template + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists(), "Built template.yaml should exist") + + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Verify Fn::ForEach structure is preserved + resources = built_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + self.assertIn(foreach_key, resources, "Fn::ForEach::Functions should be preserved in built template") + + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list) + self.assertEqual(len(foreach_block), 3) + self.assertEqual(foreach_block[0], "FunctionName") + self.assertEqual(foreach_block[1], ["Alpha", "Beta"]) + + # Verify Mappings section was generated + mappings = built_template.get("Mappings", {}) + mapping_name = "SAMCodeUriFunctions" + self.assertIn(mapping_name, mappings, f"Built template should contain {mapping_name} Mapping") + + # Verify each collection value has an entry in the Mapping + mapping_entries = mappings[mapping_name] + self.assertIn("Alpha", mapping_entries, "Mapping should have entry for Alpha") + self.assertIn("Beta", mapping_entries, "Mapping should have entry for Beta") + + # Verify each entry has a CodeUri pointing to the build artifact + alpha_codeuri = mapping_entries["Alpha"].get("CodeUri") + beta_codeuri = mapping_entries["Beta"].get("CodeUri") + self.assertIsNotNone(alpha_codeuri, "Alpha should have a CodeUri in Mapping") + self.assertIsNotNone(beta_codeuri, "Beta should have a CodeUri in Mapping") + + # The build artifact paths should be different (each function has its own source) + self.assertNotEqual(alpha_codeuri, beta_codeuri, "Alpha and Beta should have different build artifact paths") + + # Verify CodeUri in the ForEach body is replaced with Fn::FindInMap + body = foreach_block[2] + function_template = body.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + codeuri = properties.get("CodeUri") + + self.assertIsInstance(codeuri, dict, "CodeUri should be a dict (Fn::FindInMap)") + self.assertIn("Fn::FindInMap", codeuri, "CodeUri should use Fn::FindInMap") + + findmap_args = codeuri["Fn::FindInMap"] + self.assertEqual(findmap_args[0], mapping_name, f"FindInMap should reference {mapping_name}") + self.assertEqual(findmap_args[1], {"Ref": "FunctionName"}, "FindInMap should use Ref to loop variable") + self.assertEqual(findmap_args[2], "CodeUri", "FindInMap should look up CodeUri key") + + LOG.info("Successfully verified dynamic CodeUri generates Mappings in build output") + + def test_build_specific_expanded_function(self): + """ + Test that sam build with function_identifier builds only the specified expanded function. + + This test verifies: + 1. Build succeeds when specifying an expanded function name (AlphaFunction) + 2. Only AlphaFunction is built (not BetaFunction or ConfigFunction) + 3. Build artifacts exist for AlphaFunction in .aws-sam/build/ + + Validates Requirements: 3.2 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with function_identifier to build only AlphaFunction + # This tests the --resource-id functionality (Requirement 3.2) + target_function = "AlphaFunction" + cmdlist = self.get_command_list(parameter_overrides=overrides, function_identifier=target_function) + + LOG.info(f"Running sam build for specific function {target_function} using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify AlphaFunction was built + # Requirement 3.2: Build only the specific function when --resource-id is specified + self._verify_function_built(target_function) + + # Verify BetaFunction was NOT built (only AlphaFunction should be built) + self.assertNotIn( + "BetaFunction", build_dir_files, "BetaFunction should NOT be built when building only AlphaFunction" + ) + + # Verify ConfigFunction was NOT built + self.assertNotIn( + "ConfigFunction", build_dir_files, "ConfigFunction should NOT be built when building only AlphaFunction" + ) + + LOG.info(f"Successfully verified only {target_function} was built") + + def _verify_function_built(self, function_logical_id: str): + """ + Verify that a function was built and its artifacts exist. + + Args: + function_logical_id: The logical ID of the function to verify + """ + build_dir_files = os.listdir(str(self.default_build_dir)) + + # Verify function directory exists in build output + # Requirement 3.3: Build artifacts exist in .aws-sam/build/ + self.assertIn( + function_logical_id, + build_dir_files, + f"Function {function_logical_id} should have build artifacts in .aws-sam/build/", + ) + + # Verify the function artifact directory exists and contains expected files + resource_artifact_dir = self.default_build_dir.joinpath(function_logical_id) + self.assertTrue( + resource_artifact_dir.exists(), f"Build artifact directory for {function_logical_id} should exist" + ) + + # Verify expected Python files are present + all_artifacts = set(os.listdir(str(resource_artifact_dir))) + actual_files = all_artifacts.intersection(self.EXPECTED_FILES_PROJECT_MANIFEST) + self.assertEqual( + actual_files, + self.EXPECTED_FILES_PROJECT_MANIFEST, + f"Function {function_logical_id} should have expected build artifacts", + ) + + LOG.info(f"Verified function {function_logical_id} was built successfully") + + def test_build_preserves_original_template_with_foreach(self): + """ + Test that sam build preserves the original Fn::ForEach structure in the built template. + + This test verifies: + 1. Build succeeds with language extensions template + 2. The built template at .aws-sam/build/template.yaml preserves the original Fn::ForEach structure + 3. The expanded functions (AlphaFunction, BetaFunction) are built correctly with artifacts + 4. The built template is suitable for CloudFormation deployment (contains Fn::ForEach) + + Validates Requirements: 6.1, 6.5, 16.1, 16.6 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with runtime override + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build to verify original template preservation using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + # Requirement 6.1: sam build successfully builds functions generated by Fn::ForEach + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists(), "Built template.yaml should exist") + + # Read the built template + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Requirement 6.5, 16.6: Verify the built template preserves the original Fn::ForEach structure + resources = built_template.get("Resources", {}) + + # Check that Fn::ForEach::Functions key exists in Resources (original structure preserved) + foreach_key = "Fn::ForEach::Functions" + self.assertIn( + foreach_key, + resources, + f"Built template should preserve original {foreach_key} structure for CloudFormation deployment", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual(len(foreach_block), 3, "Fn::ForEach block should have 3 elements: variable, collection, body") + + # Verify the loop variable + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "FunctionName", "Loop variable should be 'FunctionName'") + + # Verify the collection + collection = foreach_block[1] + self.assertIsInstance(collection, list, "Collection should be a list") + self.assertEqual(collection, ["Alpha", "Beta"], "Collection should contain ['Alpha', 'Beta']") + + # Verify the body template contains the function definition + body_template = foreach_block[2] + self.assertIsInstance(body_template, dict, "Body template should be a dict") + self.assertIn("${FunctionName}Function", body_template, "Body should contain function template") + + # Verify the function template structure + function_template = body_template["${FunctionName}Function"] + self.assertEqual( + function_template.get("Type"), + "AWS::Serverless::Function", + "Function template should have correct Type", + ) + + LOG.info("Verified built template preserves original Fn::ForEach structure") + + # Requirement 16.1: Verify expanded functions are built correctly + # Even though the template preserves Fn::ForEach, the build artifacts should exist + # for the expanded function names (AlphaFunction, BetaFunction) + build_dir_files = os.listdir(str(self.default_build_dir)) + + for function_name in self.FOREACH_GENERATED_FUNCTIONS: + self.assertIn( + function_name, + build_dir_files, + f"Expanded function {function_name} should have build artifacts in .aws-sam/build/", + ) + + # Verify the function artifact directory exists and contains expected files + resource_artifact_dir = self.default_build_dir.joinpath(function_name) + self.assertTrue( + resource_artifact_dir.exists(), + f"Build artifact directory for {function_name} should exist", + ) + + # Verify expected Python files are present + all_artifacts = set(os.listdir(str(resource_artifact_dir))) + actual_files = all_artifacts.intersection(self.EXPECTED_FILES_PROJECT_MANIFEST) + self.assertEqual( + actual_files, + self.EXPECTED_FILES_PROJECT_MANIFEST, + f"Function {function_name} should have expected build artifacts", + ) + + LOG.info(f"Verified expanded function {function_name} was built correctly") + + LOG.info("Successfully verified sam build preserves original template and builds expanded functions") + + def _verify_conditional_policies_resolved(self): + """ + Verify that conditional DeletionPolicy and UpdateReplacePolicy are resolved in the built template. + + The template uses Fn::If to set DeletionPolicy/UpdateReplacePolicy based on the IsProd condition. + + Note: Since the original template is preserved with Fn::ForEach structure, the expanded function + names (AlphaFunction, BetaFunction) don't exist directly in the Resources section. Instead, we + verify that the Fn::ForEach body template and other regular functions have their conditional + policies preserved (they will be resolved by CloudFormation server-side). + """ + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists(), "Built template.yaml should exist") + + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + resources = built_template.get("Resources", {}) + + # Check regular functions (not generated by Fn::ForEach) have DeletionPolicy/UpdateReplacePolicy + regular_functions = [self.CONFIG_FUNCTION] + + for function_name in regular_functions: + self.assertIn(function_name, resources, f"Function {function_name} should exist in built template") + resource = resources[function_name] + + # Check DeletionPolicy exists (it may be Fn::If or resolved string) + if "DeletionPolicy" in resource: + deletion_policy = resource["DeletionPolicy"] + # The policy can be either a string (resolved) or a dict with Fn::If (preserved for CloudFormation) + if isinstance(deletion_policy, str): + self.assertIn( + deletion_policy, + ["Delete", "Retain", "Snapshot"], + f"DeletionPolicy for {function_name} should be a valid policy value", + ) + LOG.info(f"Verified {function_name} DeletionPolicy resolved to: {deletion_policy}") + elif isinstance(deletion_policy, dict) and "Fn::If" in deletion_policy: + LOG.info(f"Verified {function_name} DeletionPolicy preserved as Fn::If for CloudFormation") + else: + self.fail(f"DeletionPolicy for {function_name} has unexpected format: {deletion_policy}") + + # Check UpdateReplacePolicy exists (it may be Fn::If or resolved string) + if "UpdateReplacePolicy" in resource: + update_replace_policy = resource["UpdateReplacePolicy"] + # The policy can be either a string (resolved) or a dict with Fn::If (preserved for CloudFormation) + if isinstance(update_replace_policy, str): + self.assertIn( + update_replace_policy, + ["Delete", "Retain", "Snapshot"], + f"UpdateReplacePolicy for {function_name} should be a valid policy value", + ) + LOG.info(f"Verified {function_name} UpdateReplacePolicy resolved to: {update_replace_policy}") + elif isinstance(update_replace_policy, dict) and "Fn::If" in update_replace_policy: + LOG.info(f"Verified {function_name} UpdateReplacePolicy preserved as Fn::If for CloudFormation") + else: + self.fail(f"UpdateReplacePolicy for {function_name} has unexpected format: {update_replace_policy}") + + # Verify Fn::ForEach block has conditional policies in its body template + foreach_key = "Fn::ForEach::Functions" + if foreach_key in resources: + foreach_block = resources[foreach_key] + if len(foreach_block) >= 3: + body_template = foreach_block[2] + function_template_key = "${FunctionName}Function" + if function_template_key in body_template: + function_template = body_template[function_template_key] + # Verify DeletionPolicy exists in the template + if "DeletionPolicy" in function_template: + LOG.info("Verified Fn::ForEach body template has DeletionPolicy") + # Verify UpdateReplacePolicy exists in the template + if "UpdateReplacePolicy" in function_template: + LOG.info("Verified Fn::ForEach body template has UpdateReplacePolicy") + + def test_build_nested_foreach_dynamic_codeuri_generates_mappings(self): + """ + Test that sam build with nested Fn::ForEach and dynamic CodeUri generates Mappings. + + Validates Requirements: 25.4, 25.8 + """ + self.template_path = str( + Path(self.test_data_path, "language-extensions-nested-foreach-dynamic-codeuri", "template.yaml") + ) + + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + cmdlist = self.get_command_list(parameter_overrides=overrides) + command_result = run_command(cmdlist, cwd=self.working_dir) + + self.assertEqual( + command_result.process.returncode, + 0, + f"Build failed with stderr: {command_result.stderr.decode('utf-8')}", + ) + + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists()) + + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Verify nested Fn::ForEach structure is preserved + resources = built_template.get("Resources", {}) + outer_key = "Fn::ForEach::Environments" + self.assertIn(outer_key, resources) + + outer_block = resources[outer_key] + self.assertEqual(outer_block[0], "Env") + self.assertEqual(outer_block[1], ["dev", "prod"]) + + inner_block = outer_block[2]["Fn::ForEach::Services"] + self.assertEqual(inner_block[0], "Service") + self.assertEqual(inner_block[1], ["Users", "Orders"]) + + # Verify Mappings section was generated + mappings = built_template.get("Mappings", {}) + self.assertIn("SAMCodeUriServices", mappings) + self.assertIn("Users", mappings["SAMCodeUriServices"]) + self.assertIn("Orders", mappings["SAMCodeUriServices"]) + + # Verify CodeUri replaced with Fn::FindInMap + inner_body = inner_block[2] + props = inner_body["${Env}${Service}Function"]["Properties"] + codeuri = props["CodeUri"] + self.assertIsInstance(codeuri, dict) + self.assertIn("Fn::FindInMap", codeuri) + self.assertEqual(codeuri["Fn::FindInMap"][0], "SAMCodeUriServices") + + # Verify all expanded functions were built + for env in ["dev", "prod"]: + for svc in ["Users", "Orders"]: + func_dir = self.default_build_dir.joinpath(f"{env}{svc}Function") + self.assertTrue(func_dir.exists(), f"Build artifact for {env}{svc}Function should exist") + + +@pytest.mark.python +class TestBuildCommand_LanguageExtensions_NestedStacks(BuildIntegBase): + """ + Integration tests for sam build with nested stacks using CloudFormation Language Extensions. + + Tests that sam build correctly processes parent templates that reference + child stacks using AWS::LanguageExtensions with Fn::ForEach. + + Validates Requirements: 10.1, 10.2, 10.3, 13.4 + """ + + template = "language-extensions-nested/parent.yaml" + + # Expected files in the build artifact for Python functions + EXPECTED_FILES_PROJECT_MANIFEST = { + "__init__.py", + "main.py", + } + + # Parent function + PARENT_FUNCTION = "ParentFunction" + + # Functions generated by Fn::ForEach in child stack + CHILD_FOREACH_FUNCTIONS = ["ChildAlphaFunction", "ChildBetaFunction"] + + def _get_python_version(self): + """Get the current Python version in the format expected by Lambda runtimes.""" + return f"python{sys.version_info.major}.{sys.version_info.minor}" + + def test_build_nested_stack_with_language_extensions(self): + """ + Test that sam build succeeds with nested stacks using language extensions. + + This test verifies: + 1. Build succeeds with parent template referencing child stack with Fn::ForEach + 2. Parent function (ParentFunction) is built + 3. Child functions generated by Fn::ForEach (ChildAlphaFunction, ChildBetaFunction) are built + 4. Build artifacts exist in .aws-sam/build/ for all functions + + Validates Requirements: 10.1, 10.2, 10.3, 13.4 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with runtime override + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build with nested stacks language extensions template using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify parent function is built + # Requirement 10.1: Parent stack functions are built + self._verify_function_built(self.PARENT_FUNCTION) + + # Verify child stack directory exists + # Requirement 10.2: Nested stacks are processed + self.assertIn("ChildStack", build_dir_files, "ChildStack directory should exist in build output") + + # Verify child functions from Fn::ForEach are built + # Requirement 10.3: Functions in nested stacks using language extensions are built + child_stack_dir = self.default_build_dir.joinpath("ChildStack") + self.assertTrue(child_stack_dir.exists(), "ChildStack build directory should exist") + + child_build_files = os.listdir(str(child_stack_dir)) + for function_name in self.CHILD_FOREACH_FUNCTIONS: + self.assertIn( + function_name, child_build_files, f"Child function {function_name} should be built in nested stack" + ) + + # Verify the function artifact directory contains expected files + function_dir = child_stack_dir.joinpath(function_name) + self.assertTrue(function_dir.exists(), f"Build artifact directory for {function_name} should exist") + + all_artifacts = set(os.listdir(str(function_dir))) + actual_files = all_artifacts.intersection(self.EXPECTED_FILES_PROJECT_MANIFEST) + self.assertEqual( + actual_files, + self.EXPECTED_FILES_PROJECT_MANIFEST, + f"Child function {function_name} should have expected build artifacts", + ) + + LOG.info("Successfully verified nested stack with language extensions was built") + + def _verify_function_built(self, function_logical_id: str): + """ + Verify that a function was built and its artifacts exist. + + Args: + function_logical_id: The logical ID of the function to verify + """ + build_dir_files = os.listdir(str(self.default_build_dir)) + + # Verify function directory exists in build output + self.assertIn( + function_logical_id, + build_dir_files, + f"Function {function_logical_id} should have build artifacts in .aws-sam/build/", + ) + + # Verify the function artifact directory exists and contains expected files + resource_artifact_dir = self.default_build_dir.joinpath(function_logical_id) + self.assertTrue( + resource_artifact_dir.exists(), f"Build artifact directory for {function_logical_id} should exist" + ) + + # Verify expected Python files are present + all_artifacts = set(os.listdir(str(resource_artifact_dir))) + actual_files = all_artifacts.intersection(self.EXPECTED_FILES_PROJECT_MANIFEST) + self.assertEqual( + actual_files, + self.EXPECTED_FILES_PROJECT_MANIFEST, + f"Function {function_logical_id} should have expected build artifacts", + ) + + LOG.info(f"Verified function {function_logical_id} was built successfully") + + +@pytest.mark.python +class TestBuildCommand_LanguageExtensions_ParameterCollection(BuildIntegBase): + """ + Integration tests for sam build with Fn::ForEach using parameter reference for collection. + + Tests that sam build correctly processes templates where the Fn::ForEach collection + is a parameter reference (!Ref FunctionNames) instead of a static list. + + Validates Requirements: 6.1 + """ + + template = "language-extensions-param-collection/template.yaml" + + # Expected files in the build artifact for Python functions + EXPECTED_FILES_PROJECT_MANIFEST = { + "__init__.py", + "main.py", + } + + def _get_python_version(self): + """Get the current Python version in the format expected by Lambda runtimes.""" + return f"python{sys.version_info.major}.{sys.version_info.minor}" + + def test_build_with_parameter_collection_default_values(self): + """ + Test that sam build succeeds with Fn::ForEach using parameter reference with default values. + + This test verifies: + 1. Build succeeds when Fn::ForEach collection is !Ref FunctionNames + 2. Default parameter value "Alpha,Beta" is used + 3. Expanded functions (AlphaFunction, BetaFunction) are built correctly + 4. Build artifacts exist in .aws-sam/build/ for all functions + + Validates Requirements: 6.1 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Run sam build with runtime override (uses default FunctionNames="Alpha,Beta") + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build with parameter collection template using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify expanded functions from Fn::ForEach are built + # Default collection is ["Alpha", "Beta"] + expected_functions = ["AlphaFunction", "BetaFunction"] + for function_name in expected_functions: + self._verify_function_built(function_name) + + LOG.info("Successfully verified build with parameter collection using default values") + + def test_build_with_parameter_collection_override(self): + """ + Test that sam build succeeds with Fn::ForEach using --parameter-overrides for collection. + + This test verifies: + 1. Build succeeds when FunctionNames parameter is overridden via --parameter-overrides + 2. Overridden value "Alpha,Beta" generates AlphaFunction and BetaFunction + 3. Build artifacts exist in .aws-sam/build/ for all functions + + Validates Requirements: 6.1 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + # Override FunctionNames parameter with "Alpha,Beta" + overrides = {"Runtime": runtime, "FunctionNames": "Alpha,Beta"} + + # Run sam build with parameter overrides + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build with parameter override FunctionNames='Alpha,Beta' using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify expanded functions from Fn::ForEach are built + # Overridden collection is ["Alpha", "Beta"] + expected_functions = ["AlphaFunction", "BetaFunction"] + for function_name in expected_functions: + self._verify_function_built(function_name) + + LOG.info("Successfully verified build with parameter collection override 'Alpha,Beta'") + + def test_build_with_different_parameter_values(self): + """ + Test that sam build succeeds with different parameter values for the collection. + + This test verifies: + 1. Build succeeds when FunctionNames is overridden with different values + 2. Overridden value "Gamma,Delta,Epsilon" generates GammaFunction, DeltaFunction, EpsilonFunction + 3. Build artifacts exist in .aws-sam/build/ for all functions + + Validates Requirements: 6.1 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + # Override FunctionNames parameter with "Gamma,Delta,Epsilon" + overrides = {"Runtime": runtime, "FunctionNames": "Gamma,Delta,Epsilon"} + + # Run sam build with parameter overrides + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info( + f"Running sam build with parameter override FunctionNames='Gamma,Delta,Epsilon' using runtime {runtime}" + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + build_dir_files = os.listdir(str(self.default_build_dir)) + self.assertIn("template.yaml", build_dir_files) + + # Verify expanded functions from Fn::ForEach are built + # Overridden collection is ["Gamma", "Delta", "Epsilon"] + expected_functions = ["GammaFunction", "DeltaFunction", "EpsilonFunction"] + for function_name in expected_functions: + self._verify_function_built(function_name) + + LOG.info("Successfully verified build with parameter collection override 'Gamma,Delta,Epsilon'") + + def test_build_preserves_foreach_with_parameter_ref(self): + """ + Test that sam build preserves the Fn::ForEach structure with parameter reference in built template. + + This test verifies: + 1. Build succeeds with parameter collection template + 2. The built template preserves the Fn::ForEach structure + 3. The collection in the built template is the !Ref FunctionNames (parameter reference) + 4. The expanded functions are built correctly + + Validates Requirements: 6.1 + """ + # Use the current Python version for the runtime + runtime = self._get_python_version() + overrides = {"Runtime": runtime, "FunctionNames": "Alpha,Beta"} + + # Run sam build with parameter overrides + cmdlist = self.get_command_list(parameter_overrides=overrides) + + LOG.info(f"Running sam build to verify Fn::ForEach preservation with parameter ref using runtime {runtime}") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, 0, f"Build failed with stderr: {command_result.stderr.decode('utf-8')}" + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Verify template.yaml exists in build directory + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists(), "Built template.yaml should exist") + + # Read the built template + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Verify the built template preserves the original Fn::ForEach structure + resources = built_template.get("Resources", {}) + + # Check that Fn::ForEach::Functions key exists in Resources (original structure preserved) + foreach_key = "Fn::ForEach::Functions" + self.assertIn( + foreach_key, + resources, + f"Built template should preserve original {foreach_key} structure for CloudFormation deployment", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual(len(foreach_block), 3, "Fn::ForEach block should have 3 elements: variable, collection, body") + + # Verify the loop variable + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "Name", "Loop variable should be 'Name'") + + # Verify the collection is a Ref to FunctionNames parameter + collection = foreach_block[1] + # The collection should be preserved as a Ref to the parameter + self.assertIsInstance(collection, dict, "Collection should be a dict (Ref)") + self.assertIn("Ref", collection, "Collection should be a Ref to FunctionNames parameter") + self.assertEqual(collection["Ref"], "FunctionNames", "Collection should reference FunctionNames parameter") + + # Verify the body template contains the function definition + body_template = foreach_block[2] + self.assertIsInstance(body_template, dict, "Body template should be a dict") + self.assertIn("${Name}Function", body_template, "Body should contain function template") + + # Verify the function template structure + function_template = body_template["${Name}Function"] + self.assertEqual( + function_template.get("Type"), + "AWS::Serverless::Function", + "Function template should have correct Type", + ) + + LOG.info("Verified built template preserves Fn::ForEach structure with parameter reference") + + # Verify expanded functions are built correctly + build_dir_files = os.listdir(str(self.default_build_dir)) + expected_functions = ["AlphaFunction", "BetaFunction"] + for function_name in expected_functions: + self.assertIn( + function_name, + build_dir_files, + f"Expanded function {function_name} should have build artifacts in .aws-sam/build/", + ) + + LOG.info("Successfully verified sam build preserves Fn::ForEach with parameter reference") + + def _verify_function_built(self, function_logical_id: str): + """ + Verify that a function was built and its artifacts exist. + + Args: + function_logical_id: The logical ID of the function to verify + """ + build_dir_files = os.listdir(str(self.default_build_dir)) + + # Verify function directory exists in build output + self.assertIn( + function_logical_id, + build_dir_files, + f"Function {function_logical_id} should have build artifacts in .aws-sam/build/", + ) + + # Verify the function artifact directory exists and contains expected files + resource_artifact_dir = self.default_build_dir.joinpath(function_logical_id) + self.assertTrue( + resource_artifact_dir.exists(), f"Build artifact directory for {function_logical_id} should exist" + ) + + # Verify expected Python files are present + all_artifacts = set(os.listdir(str(resource_artifact_dir))) + actual_files = all_artifacts.intersection(self.EXPECTED_FILES_PROJECT_MANIFEST) + self.assertEqual( + actual_files, + self.EXPECTED_FILES_PROJECT_MANIFEST, + f"Function {function_logical_id} should have expected build artifacts", + ) + + LOG.info(f"Verified function {function_logical_id} was built successfully") + + +@pytest.mark.python +class TestBuildCommand_LanguageExtensions_NestedForEachDepthValidation(BuildIntegBase): + """ + Integration tests for sam build with nested Fn::ForEach depth validation. + + Tests that sam build correctly validates the nesting depth of Fn::ForEach loops + and fails early with a clear error message when the limit is exceeded. + + Validates Requirements: 18.3, 18.4, 18.5, 18.7 + """ + + def test_build_fails_early_on_nested_foreach_depth_exceeded(self): + """ + Test that sam build fails early when template has 6 levels of nested Fn::ForEach. + + This test verifies: + 1. Build fails before attempting to build any resources + 2. Error message indicates the maximum nesting depth of 5 has been exceeded + 3. Error message indicates the actual nesting depth found (6) + 4. Error is raised during template processing, not during build phase + + Validates Requirements: 18.3, 18.4, 18.5, 18.7 + - 18.3: WHEN a template contains more than 5 levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL raise an error before processing + - 18.4: WHEN the nested loop limit is exceeded, THE error message SHALL clearly + indicate that the maximum nesting depth of 5 has been exceeded + - 18.5: WHEN the nested loop limit is exceeded, THE error message SHALL indicate + the actual nesting depth found in the template + - 18.7: WHEN `sam build` processes a template exceeding the nested loop limit, + THE Build_Command SHALL fail before attempting to build any resources + """ + # Use the invalid nested foreach template (6 levels of nesting) + self.template_path = str( + Path(self.test_data_path, "language-extensions-nested-foreach-invalid", "template.yaml") + ) + + # Run sam build + cmdlist = self.get_command_list() + + LOG.info("Running sam build with template that exceeds nested Fn::ForEach depth limit") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Combine stdout and stderr for error message checking + stdout_output = command_result.stdout.decode("utf-8") + stderr_output = command_result.stderr.decode("utf-8") + combined_output = stdout_output + stderr_output + + # Verify build fails (non-zero exit code) + self.assertNotEqual( + command_result.process.returncode, + 0, + f"Expected non-zero exit code but got {command_result.process.returncode}. Output: {combined_output}", + ) + + # Requirement 18.4: Error message indicates maximum nesting depth of 5 + self.assertIn( + "5", + combined_output, + f"Expected error message to mention maximum depth of 5. Actual output: {combined_output}", + ) + + # Requirement 18.5: Error message indicates actual nesting depth found (6) + self.assertIn( + "6", + combined_output, + f"Expected error message to mention actual depth of 6. Actual output: {combined_output}", + ) + + # Verify the error message mentions nesting or depth + nesting_indicators = ["nesting", "depth", "exceeds", "maximum", "nested"] + has_nesting_indicator = any(indicator.lower() in combined_output.lower() for indicator in nesting_indicators) + self.assertTrue( + has_nesting_indicator, + f"Expected error message about nesting depth. Actual output: {combined_output}", + ) + + # Requirement 18.7: Verify build failed early (no build artifacts should be created) + # If the build directory exists, it should not contain any function artifacts + if self.default_build_dir.exists(): + build_dir_files = os.listdir(str(self.default_build_dir)) + # The only file that might exist is template.yaml, but no function directories + function_dirs = [f for f in build_dir_files if f.endswith("Resource") or f.endswith("Function")] + self.assertEqual( + len(function_dirs), + 0, + f"Expected no function artifacts to be built, but found: {function_dirs}", + ) + + LOG.info("Successfully verified sam build fails early on nested Fn::ForEach depth exceeded") + + +@pytest.mark.python +class TestBuildCommand_LanguageExtensions_DynamicImageUri(BuildIntegBase): + """ + Integration tests for sam build with Fn::ForEach and dynamic ImageUri. + + Tests that sam build correctly processes templates where Fn::ForEach generates + container image Lambda functions with dynamic ImageUri. + + Validates Requirements: 6.6, 19.2 + """ + + template = "language-extensions-dynamic-imageuri/template.yaml" + + # Functions generated by Fn::ForEach + FOREACH_GENERATED_FUNCTIONS = ["alphaFunction", "betaFunction"] + + def test_build_with_dynamic_imageuri_generates_mappings(self): + """ + Test that sam build with dynamic ImageUri generates Mappings in the output template. + + This test verifies: + 1. Build succeeds with dynamic ImageUri template + 2. The built template contains a Mappings section with SAMImageUriFunctions + 3. ImageUri is replaced with Fn::FindInMap referencing the Mappings + 4. Each collection value maps to its correct image + 5. Fn::ForEach structure is preserved in the output + + Validates Requirements: 6.6, 19.2 + """ + import yaml + + # Run sam build + cmdlist = self.get_command_list() + + LOG.info("Running sam build with dynamic ImageUri template") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, + 0, + f"Build failed with stderr: {command_result.stderr.decode('utf-8')}", + ) + + # Verify build directory was created + self.assertTrue(self.default_build_dir.exists(), "Build directory should be created") + + # Read the built template + built_template_path = self.default_build_dir.joinpath("template.yaml") + self.assertTrue(built_template_path.exists(), "Built template.yaml should exist") + + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Verify Fn::ForEach structure is preserved + resources = built_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + self.assertIn(foreach_key, resources, "Fn::ForEach::Functions should be preserved in built template") + + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list) + self.assertEqual(len(foreach_block), 3) + self.assertEqual(foreach_block[0], "FunctionName") + self.assertEqual(foreach_block[1], ["alpha", "beta"]) + + # Verify Mappings section was generated + mappings = built_template.get("Mappings", {}) + mapping_name = "SAMImageUriFunctions" + self.assertIn(mapping_name, mappings, f"Built template should contain {mapping_name} Mapping") + + # Verify each collection value has an entry in the Mapping + mapping_entries = mappings[mapping_name] + self.assertIn("alpha", mapping_entries, "Mapping should have entry for alpha") + self.assertIn("beta", mapping_entries, "Mapping should have entry for beta") + + # Verify each entry has an ImageUri + alpha_imageuri = mapping_entries["alpha"].get("ImageUri") + beta_imageuri = mapping_entries["beta"].get("ImageUri") + self.assertIsNotNone(alpha_imageuri, "alpha should have an ImageUri in Mapping") + self.assertIsNotNone(beta_imageuri, "beta should have an ImageUri in Mapping") + + # The image URIs should be different (each function has its own image) + self.assertNotEqual(alpha_imageuri, beta_imageuri, "alpha and beta should have different ImageUri values") + + # Verify ImageUri in the ForEach body is replaced with Fn::FindInMap + body = foreach_block[2] + function_template = body.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + imageuri = properties.get("ImageUri") + + self.assertIsInstance(imageuri, dict, "ImageUri should be a dict (Fn::FindInMap)") + self.assertIn("Fn::FindInMap", imageuri, "ImageUri should use Fn::FindInMap") + + findmap_args = imageuri["Fn::FindInMap"] + self.assertEqual(findmap_args[0], mapping_name, f"FindInMap should reference {mapping_name}") + self.assertEqual(findmap_args[1], {"Ref": "FunctionName"}, "FindInMap should use Ref to loop variable") + self.assertEqual(findmap_args[2], "ImageUri", "FindInMap should look up ImageUri key") + + LOG.info("Successfully verified dynamic ImageUri generates Mappings in build output") + + def test_build_preserves_foreach_structure_with_dynamic_imageuri(self): + """ + Test that sam build preserves the original Fn::ForEach structure with dynamic ImageUri. + + This test verifies: + 1. Build succeeds with dynamic ImageUri template + 2. The built template preserves the Fn::ForEach structure + 3. The template is suitable for CloudFormation deployment + + Validates Requirements: 6.5, 16.6 + """ + import yaml + + # Run sam build + cmdlist = self.get_command_list() + + LOG.info("Running sam build to verify Fn::ForEach preservation with dynamic ImageUri") + command_result = run_command(cmdlist, cwd=self.working_dir) + + # Verify build succeeds + self.assertEqual( + command_result.process.returncode, + 0, + f"Build failed with stderr: {command_result.stderr.decode('utf-8')}", + ) + + # Read the built template + built_template_path = self.default_build_dir.joinpath("template.yaml") + with open(built_template_path, "r") as f: + built_template = yaml.safe_load(f) + + # Verify Fn::ForEach structure is preserved + resources = built_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + "Built template should preserve Fn::ForEach::Functions structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual(len(foreach_block), 3, "Fn::ForEach block should have 3 elements") + + # Verify the loop variable + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "FunctionName", "Loop variable should be 'FunctionName'") + + # Verify the collection + collection = foreach_block[1] + self.assertEqual(collection, ["alpha", "beta"], "Collection should be ['alpha', 'beta']") + + # Verify the body template contains the function definition + body_template = foreach_block[2] + self.assertIn("${FunctionName}Function", body_template, "Body should contain function template") + + # Verify the function template structure + function_template = body_template["${FunctionName}Function"] + self.assertEqual( + function_template.get("Type"), + "AWS::Serverless::Function", + "Function template should have correct Type", + ) + + # Verify PackageType is Image + properties = function_template.get("Properties", {}) + self.assertEqual( + properties.get("PackageType"), + "Image", + "PackageType should be Image", + ) + + LOG.info("Successfully verified Fn::ForEach structure is preserved with dynamic ImageUri") diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/__init__.py b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/__init__.py new file mode 100644 index 00000000000..e221cf8c30f --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/__init__.py @@ -0,0 +1 @@ +"""Alpha function package.""" diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/main.py b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/main.py new file mode 100644 index 00000000000..39670ae7c2b --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Alpha/main.py @@ -0,0 +1,12 @@ +"""Alpha function handler.""" + + +def handler(event, context): + """Lambda handler for Alpha function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Alpha", + "function": "AlphaFunction" + } + } diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/__init__.py b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/__init__.py new file mode 100644 index 00000000000..cb56181a88e --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/__init__.py @@ -0,0 +1 @@ +"""Beta function package.""" diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/main.py b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/main.py new file mode 100644 index 00000000000..4df00a34d8e --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/Beta/main.py @@ -0,0 +1,12 @@ +"""Beta function handler.""" + + +def handler(event, context): + """Lambda handler for Beta function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Beta", + "function": "BetaFunction" + } + } diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/samconfig.toml b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/samconfig.toml new file mode 100644 index 00000000000..e927116dfbb --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/samconfig.toml @@ -0,0 +1,13 @@ +version = 0.1 + +[default.deploy.parameters] +stack_name = "ledc-test" +resolve_s3 = true +s3_prefix = "ledc-test" +region = "us-west-2" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "Runtime=\"python3.13\"" +image_repositories = [] + +[default.global.parameters] +region = "us-west-2" diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/template.yaml new file mode 100644 index 00000000000..c4e7c55550e --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-codeuri/template.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for Fn::ForEach with dynamic CodeUri using ${FunctionName}. + Each expanded function has its own source directory. + +Parameters: + Runtime: + Type: String + Default: python3.13 + +Globals: + Function: + Timeout: 10 + MemorySize: 128 + Runtime: !Ref Runtime + +Resources: + # Fn::ForEach with dynamic CodeUri - each function gets its own source directory + # AlphaFunction -> CodeUri: Alpha/ + # BetaFunction -> CodeUri: Beta/ + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ${FunctionName}/ + FunctionName: + Fn::Sub: ${FunctionName}-handler + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} diff --git a/tests/integration/testdata/buildcmd/language-extensions-dynamic-imageuri/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-dynamic-imageuri/template.yaml new file mode 100644 index 00000000000..26f70a15f2b --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-dynamic-imageuri/template.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam build with Fn::ForEach and dynamic ImageUri. + This template generates multiple container image Lambda functions using Fn::ForEach + with dynamic ImageUri (ImageUri: emulation-python3.9-${FunctionName}:latest), which should result + in a generated Mappings section and Fn::FindInMap references in the build output. + +Resources: + # Test Fn::ForEach generating multiple container image functions with dynamic ImageUri + # This generates alphaFunction and betaFunction, each with their own image + # alphaFunction -> ImageUri: emulation-python3.9-alpha:latest + # betaFunction -> ImageUri: emulation-python3.9-beta:latest + # NOTE: Collection values must be lowercase because Docker repository names are case-sensitive + # and must be lowercase. + Fn::ForEach::Functions: + - FunctionName + - - alpha + - beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${FunctionName}Function + PackageType: Image + ImageUri: emulation-python3.9-${FunctionName}:latest + Timeout: 30 + MemorySize: 512 + +Outputs: + alphaFunctionArn: + Description: ARN of the alpha function + Value: !GetAtt alphaFunction.Arn + betaFunctionArn: + Description: ARN of the beta function + Value: !GetAtt betaFunction.Arn diff --git a/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/__init__.py b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/__init__.py new file mode 100644 index 00000000000..31bd7e49736 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/__init__.py @@ -0,0 +1 @@ +# Lambda handler package for Fn::FindInMap with DefaultValue test template diff --git a/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/main.py b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/main.py new file mode 100644 index 00000000000..ce27516f314 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/main.py @@ -0,0 +1,146 @@ +"""Lambda handlers for Fn::FindInMap with DefaultValue test template. + +This module provides handlers for testing Fn::FindInMap with DefaultValue option. +The handlers demonstrate: +- Existing key lookup (returns mapped value) +- Non-existent key lookup (returns DefaultValue) +- Integration with Fn::ForEach using Fn::FindInMap with DefaultValue + +Each function receives configuration via environment variables set by the template. +""" + +import json +import os + + +def handler(event, context): + """Default Lambda handler that returns function configuration. + + This handler is used by functions that don't have a specific handler + defined in the FunctionConfig mapping (uses DefaultValue). + + Args: + event: Lambda event data + context: Lambda context object + + Returns: + dict: Response with function configuration information + """ + function_name = os.environ.get("FUNCTION_NAME", "Unknown") + log_level = os.environ.get("LOG_LEVEL", "INFO") + test_case = os.environ.get("TEST_CASE", "unknown") + function_description = os.environ.get("FUNCTION_DESCRIPTION", "No description") + config_json = os.environ.get("CONFIG_JSON", "{}") + environment_count = os.environ.get("ENVIRONMENT_COUNT", "0") + + return { + "statusCode": 200, + "body": { + "message": f"Hello from {function_name}", + "handler": "default_handler", + "logLevel": log_level, + "testCase": test_case, + "description": function_description, + "configJson": json.loads(config_json) if config_json else {}, + "environmentCount": int(environment_count), + "source": "findinmap-default-template" + } + } + + +def alpha_handler(event, context): + """Lambda handler for Alpha function. + + This handler is used when the FunctionConfig mapping has an entry for Alpha. + Demonstrates Fn::FindInMap returning the mapped value when key exists. + + Args: + event: Lambda event data + context: Lambda context object + + Returns: + dict: Response with Alpha function information + """ + function_name = os.environ.get("FUNCTION_NAME", "Alpha") + log_level = os.environ.get("LOG_LEVEL", "INFO") + test_case = os.environ.get("TEST_CASE", "unknown") + function_description = os.environ.get("FUNCTION_DESCRIPTION", "No description") + + return { + "statusCode": 200, + "body": { + "message": f"Hello from {function_name}", + "handler": "alpha_handler", + "logLevel": log_level, + "testCase": test_case, + "description": function_description, + "mappingUsed": True, + "source": "findinmap-default-template" + } + } + + +def beta_handler(event, context): + """Lambda handler for Beta function. + + This handler is used when the FunctionConfig mapping has an entry for Beta. + Demonstrates Fn::FindInMap returning the mapped value when key exists. + + Args: + event: Lambda event data + context: Lambda context object + + Returns: + dict: Response with Beta function information + """ + function_name = os.environ.get("FUNCTION_NAME", "Beta") + log_level = os.environ.get("LOG_LEVEL", "INFO") + test_case = os.environ.get("TEST_CASE", "unknown") + function_description = os.environ.get("FUNCTION_DESCRIPTION", "No description") + + return { + "statusCode": 200, + "body": { + "message": f"Hello from {function_name}", + "handler": "beta_handler", + "logLevel": log_level, + "testCase": test_case, + "description": function_description, + "mappingUsed": True, + "source": "findinmap-default-template" + } + } + + +def default_handler(event, context): + """Default Lambda handler for functions without mapping entries. + + This handler is used when the FunctionConfig mapping does NOT have an entry + for the function (e.g., Gamma). Demonstrates Fn::FindInMap returning the + DefaultValue when key does not exist. + + Args: + event: Lambda event data + context: Lambda context object + + Returns: + dict: Response indicating DefaultValue was used + """ + function_name = os.environ.get("FUNCTION_NAME", "Unknown") + log_level = os.environ.get("LOG_LEVEL", "INFO") + test_case = os.environ.get("TEST_CASE", "unknown") + function_description = os.environ.get("FUNCTION_DESCRIPTION", "No description") + + return { + "statusCode": 200, + "body": { + "message": f"Hello from {function_name}", + "handler": "default_handler", + "logLevel": log_level, + "testCase": test_case, + "description": function_description, + "mappingUsed": False, + "defaultValueUsed": True, + "source": "findinmap-default-template" + } + } diff --git a/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/requirements.txt b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/requirements.txt new file mode 100644 index 00000000000..c09715dd75d --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/src/requirements.txt @@ -0,0 +1 @@ +# No external dependencies required for this test template diff --git a/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/template.yaml new file mode 100644 index 00000000000..39dcbc6729c --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-findinmap-default/template.yaml @@ -0,0 +1,252 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for Fn::FindInMap with DefaultValue option. + Demonstrates: + - Fn::FindInMap with DefaultValue where key exists (returns mapped value) + - Fn::FindInMap with DefaultValue where key does NOT exist (returns default) + - Integration with Fn::ForEach using Fn::FindInMap with DefaultValue + Validates Requirements: 17.5, 5A.1, 5A.2 + +Parameters: + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - staging + - prod + Description: Deployment environment + +Mappings: + # Environment-specific configuration mapping + EnvironmentConfig: + dev: + MemorySize: "128" + Timeout: "10" + LogLevel: DEBUG + staging: + MemorySize: "256" + Timeout: "20" + LogLevel: INFO + prod: + MemorySize: "512" + Timeout: "30" + LogLevel: WARN + + # Function-specific configuration mapping + # Note: Only Alpha and Beta are defined, Gamma will use DefaultValue + FunctionConfig: + Alpha: + Handler: alpha_handler + Description: Alpha function handler + Beta: + Handler: beta_handler + Description: Beta function handler + # Gamma is intentionally NOT defined to test DefaultValue + +Globals: + Function: + Runtime: !Ref Runtime + +Resources: + # Test 1: Fn::FindInMap with DefaultValue where key EXISTS (returns mapped value) + # This function uses the 'dev' environment which exists in EnvironmentConfig + # Expected: MemorySize = 128 (from mapping), not the default 64 + ExistingKeyFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: existing-key-function + MemorySize: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - MemorySize + - DefaultValue: "64" + Timeout: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - Timeout + - DefaultValue: "5" + Environment: + Variables: + LOG_LEVEL: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - LogLevel + - DefaultValue: ERROR + TEST_CASE: existing_key + + # Test 2: Fn::FindInMap with DefaultValue where key does NOT exist (returns default) + # This function looks up 'NonExistentEnv' which doesn't exist in EnvironmentConfig + # Expected: MemorySize = 64 (DefaultValue), Timeout = 5 (DefaultValue) + NonExistentKeyFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: non-existent-key-function + MemorySize: + Fn::FindInMap: + - EnvironmentConfig + - NonExistentEnv + - MemorySize + - DefaultValue: "64" + Timeout: + Fn::FindInMap: + - EnvironmentConfig + - NonExistentEnv + - Timeout + - DefaultValue: "5" + Environment: + Variables: + LOG_LEVEL: + Fn::FindInMap: + - EnvironmentConfig + - NonExistentEnv + - LogLevel + - DefaultValue: ERROR + TEST_CASE: non_existent_key + + # Test 3: Fn::ForEach with Fn::FindInMap using DefaultValue + # This generates functions for Alpha, Beta, and Gamma + # Alpha and Beta exist in FunctionConfig mapping (use mapped values) + # Gamma does NOT exist in FunctionConfig mapping (uses DefaultValue) + Fn::ForEach::ConfiguredFunctions: + - FunctionName + - - Alpha + - Beta + - Gamma + - ${FunctionName}ConfiguredFunction: + Type: AWS::Serverless::Function + Properties: + # Handler uses Fn::FindInMap with DefaultValue + # Alpha -> alpha_handler, Beta -> beta_handler, Gamma -> default_handler (DefaultValue) + Handler: + Fn::FindInMap: + - FunctionConfig + - !Sub ${FunctionName} + - Handler + - DefaultValue: default_handler + CodeUri: src/ + FunctionName: + Fn::Sub: ${FunctionName}-configured-function + MemorySize: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - MemorySize + - DefaultValue: "64" + Timeout: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - Timeout + - DefaultValue: "5" + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + # Description uses Fn::FindInMap with DefaultValue + # Alpha/Beta get mapped description, Gamma gets default + FUNCTION_DESCRIPTION: + Fn::FindInMap: + - FunctionConfig + - !Sub ${FunctionName} + - Description + - DefaultValue: Default function description + LOG_LEVEL: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - LogLevel + - DefaultValue: ERROR + TEST_CASE: foreach_with_findinmap + + # Test 4: Nested Fn::FindInMap with DefaultValue in Fn::ToJsonString + # Combines multiple language extensions features + CombinedFeaturesFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: combined-features-function + MemorySize: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - MemorySize + - DefaultValue: "64" + Timeout: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - Timeout + - DefaultValue: "5" + Environment: + Variables: + # Fn::ToJsonString with Fn::FindInMap using DefaultValue + CONFIG_JSON: + Fn::ToJsonString: + environment: !Ref Environment + logLevel: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - LogLevel + - DefaultValue: ERROR + memorySize: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - MemorySize + - DefaultValue: "64" + # This key doesn't exist, so DefaultValue is used + customSetting: + Fn::FindInMap: + - EnvironmentConfig + - !Ref Environment + - CustomSetting + - DefaultValue: default-custom-value + # Fn::Length to count environments + ENVIRONMENT_COUNT: + Fn::Length: + - dev + - staging + - prod + TEST_CASE: combined_features + +Outputs: + ExistingKeyFunctionArn: + Description: ARN of the function testing existing key lookup + Value: !GetAtt ExistingKeyFunction.Arn + + NonExistentKeyFunctionArn: + Description: ARN of the function testing non-existent key lookup (uses DefaultValue) + Value: !GetAtt NonExistentKeyFunction.Arn + + AlphaConfiguredFunctionArn: + Description: ARN of Alpha function (key exists in FunctionConfig) + Value: !GetAtt AlphaConfiguredFunction.Arn + + BetaConfiguredFunctionArn: + Description: ARN of Beta function (key exists in FunctionConfig) + Value: !GetAtt BetaConfiguredFunction.Arn + + GammaConfiguredFunctionArn: + Description: ARN of Gamma function (key does NOT exist, uses DefaultValue) + Value: !GetAtt GammaConfiguredFunction.Arn + + CombinedFeaturesFunctionArn: + Description: ARN of the function combining multiple language extension features + Value: !GetAtt CombinedFeaturesFunction.Arn diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py new file mode 100644 index 00000000000..8a7e7d83131 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py @@ -0,0 +1,5 @@ +"""Orders service handler.""" + + +def handler(event, context): + return {"statusCode": 200, "body": {"service": "Orders"}} diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py new file mode 100644 index 00000000000..0e7d935ba5f --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py @@ -0,0 +1,5 @@ +"""Users service handler.""" + + +def handler(event, context): + return {"statusCode": 200, "body": {"service": "Users"}} diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/template.yaml new file mode 100644 index 00000000000..eec736baa29 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-dynamic-codeuri/template.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for nested Fn::ForEach with dynamic CodeUri. + Outer loop over environments, inner loop over services with CodeUri: ./services/${Service}. + +Parameters: + Runtime: + Type: String + Default: python3.13 + +Globals: + Function: + Timeout: 10 + MemorySize: 128 + Runtime: !Ref Runtime + +Resources: + Fn::ForEach::Environments: + - Env + - - dev + - prod + - Fn::ForEach::Services: + - Service + - - Users + - Orders + - ${Env}${Service}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./services/${Service} + Environment: + Variables: + ENV_NAME: !Sub ${Env} + SERVICE_NAME: !Sub ${Service} diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-invalid/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-invalid/template.yaml new file mode 100644 index 00000000000..0643939c58c --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-invalid/template.yaml @@ -0,0 +1,33 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template with 6 levels of nested Fn::ForEach (exceeds the maximum allowed limit of 5). + This template should fail validation with a clear error message. + +Resources: + # 6 levels of nested Fn::ForEach - exceeds the maximum allowed limit + Fn::ForEach::Level1: + - L1 + - [A] + - Fn::ForEach::Level2: + - L2 + - [B] + - Fn::ForEach::Level3: + - L3 + - [C] + - Fn::ForEach::Level4: + - L4 + - [D] + - Fn::ForEach::Level5: + - L5 + - [E] + - Fn::ForEach::Level6: + - L6 + - [F] + - ${L1}${L2}${L3}${L4}${L5}${L6}Resource: + Type: AWS::CloudFormation::WaitConditionHandle + # Using WaitConditionHandle as it requires no properties + # and is the simplest resource type for testing nesting diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-valid/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-valid/template.yaml new file mode 100644 index 00000000000..2a38e05ebfd --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested-foreach-valid/template.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template with 5 levels of nested Fn::ForEach (at the maximum allowed limit). + This template should be valid and process successfully. + +Resources: + # 5 levels of nested Fn::ForEach - at the maximum allowed limit + Fn::ForEach::Level1: + - L1 + - [A] + - Fn::ForEach::Level2: + - L2 + - [B] + - Fn::ForEach::Level3: + - L3 + - [C] + - Fn::ForEach::Level4: + - L4 + - [D] + - Fn::ForEach::Level5: + - L5 + - [E] + - ${L1}${L2}${L3}${L4}${L5}Resource: + Type: AWS::CloudFormation::WaitConditionHandle + # Using WaitConditionHandle as it requires no properties + # and is the simplest resource type for testing nesting diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested/child.yaml b/tests/integration/testdata/buildcmd/language-extensions-nested/child.yaml new file mode 100644 index 00000000000..4d2462cb132 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested/child.yaml @@ -0,0 +1,61 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Child template demonstrating Fn::ForEach in a nested stack. + This template uses AWS::LanguageExtensions to generate multiple Lambda functions. + Validates Requirement 13.4: Test templates with nested stacks using language extensions. + +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - prod + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 10 + MemorySize: 128 + Runtime: !Ref Runtime + +Resources: + # Fn::ForEach generating multiple Lambda functions in the nested stack + # This generates ChildAlphaFunction and ChildBetaFunction + Fn::ForEach::ChildFunctions: + - FunctionName + - - Alpha + - Beta + - Child${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: + Fn::Sub: child-${FunctionName}-handler + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + STACK_TYPE: child + ENVIRONMENT: !Ref Environment + # Fn::Length to demonstrate language extensions in nested stack + TOTAL_CHILD_FUNCTIONS: + Fn::Length: + - Alpha + - Beta + +Outputs: + ChildAlphaFunctionArn: + Description: ARN of the Child Alpha Lambda function + Value: !GetAtt ChildAlphaFunction.Arn + + ChildBetaFunctionArn: + Description: ARN of the Child Beta Lambda function + Value: !GetAtt ChildBetaFunction.Arn diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested/parent.yaml b/tests/integration/testdata/buildcmd/language-extensions-nested/parent.yaml new file mode 100644 index 00000000000..99e4d976c9b --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested/parent.yaml @@ -0,0 +1,54 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::Serverless-2016-10-31 + +Description: > + Parent template for nested stacks with language extensions test. + This template references a child stack that uses AWS::LanguageExtensions. + Validates Requirement 13.4: Test templates with nested stacks using language extensions. + +Parameters: + Environment: + Type: String + Default: dev + AllowedValues: + - dev + - prod + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 10 + MemorySize: 128 + Runtime: !Ref Runtime + +Resources: + # Parent-level Lambda function + ParentFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: parent-handler + Environment: + Variables: + STACK_TYPE: parent + ENVIRONMENT: !Ref Environment + + # Nested stack using AWS::Serverless::Application + # The child template uses AWS::LanguageExtensions with Fn::ForEach + ChildStack: + Type: AWS::Serverless::Application + Properties: + Location: child.yaml + Parameters: + Environment: !Ref Environment + Runtime: !Ref Runtime + +Outputs: + ParentFunctionArn: + Description: ARN of the parent Lambda function + Value: !GetAtt ParentFunction.Arn diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested/src/__init__.py b/tests/integration/testdata/buildcmd/language-extensions-nested/src/__init__.py new file mode 100644 index 00000000000..c1ce548860f --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested/src/__init__.py @@ -0,0 +1 @@ +# Lambda handler package for nested stack language extensions test diff --git a/tests/integration/testdata/buildcmd/language-extensions-nested/src/main.py b/tests/integration/testdata/buildcmd/language-extensions-nested/src/main.py new file mode 100644 index 00000000000..5e909b2da40 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-nested/src/main.py @@ -0,0 +1,25 @@ +""" +Simple Lambda handler for nested stack language extensions test. +Used by both parent and child templates. +""" + +import os + + +def handler(event, context): + """ + Lambda handler that returns information about the function. + + Returns: + dict: Response containing function metadata + """ + return { + "statusCode": 200, + "body": { + "message": "Hello from nested stack test", + "function_name": os.environ.get("FUNCTION_NAME", "unknown"), + "stack_type": os.environ.get("STACK_TYPE", "unknown"), + "environment": os.environ.get("ENVIRONMENT", "unknown"), + "total_child_functions": os.environ.get("TOTAL_CHILD_FUNCTIONS", "N/A") + } + } diff --git a/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/__init__.py b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/__init__.py new file mode 100644 index 00000000000..0ad0a94863a --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/__init__.py @@ -0,0 +1 @@ +# Lambda function source for language-extensions-param-collection test diff --git a/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/main.py b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/main.py new file mode 100644 index 00000000000..7fa6e3f64f1 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/main.py @@ -0,0 +1,29 @@ +"""Lambda handler for functions generated by Fn::ForEach with parameter collection. + +This handler is shared by all functions generated from the FunctionNames parameter. +Each function receives its name via the FUNCTION_NAME environment variable. +""" + +import os + + +def handler(event, context): + """Lambda handler that returns information about the function. + + Args: + event: Lambda event data + context: Lambda context object + + Returns: + dict: Response with function information + """ + function_name = os.environ.get("FUNCTION_NAME", "Unknown") + + return { + "statusCode": 200, + "body": { + "message": f"Hello from {function_name}", + "function": f"{function_name}Function", + "source": "parameter-collection-template" + } + } diff --git a/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/requirements.txt b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/requirements.txt new file mode 100644 index 00000000000..8c14dca06f1 --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-param-collection/src/requirements.txt @@ -0,0 +1 @@ +# No external dependencies required for this simple handler diff --git a/tests/integration/testdata/buildcmd/language-extensions-param-collection/template.yaml b/tests/integration/testdata/buildcmd/language-extensions-param-collection/template.yaml new file mode 100644 index 00000000000..1ead35c18df --- /dev/null +++ b/tests/integration/testdata/buildcmd/language-extensions-param-collection/template.yaml @@ -0,0 +1,52 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for Fn::ForEach with a parameter reference for the collection. + Demonstrates using !Ref to a CommaDelimitedList parameter as the ForEach collection. + This validates Requirement 17.7 - template with Fn::ForEach using a parameter reference. + +Parameters: + FunctionNames: + Type: CommaDelimitedList + Default: "Alpha,Beta" + Description: Comma-separated list of function names to generate + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 10 + MemorySize: 128 + Runtime: !Ref Runtime + +Resources: + # Fn::ForEach using !Ref FunctionNames as the collection + # With default value "Alpha,Beta", this generates AlphaFunction and BetaFunction + # Users can override with --parameter-overrides FunctionNames="Gamma,Delta,Epsilon" + Fn::ForEach::Functions: + - Name + - !Ref FunctionNames + - ${Name}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src/ + FunctionName: + Fn::Sub: ${Name}-handler + Environment: + Variables: + FUNCTION_NAME: !Sub ${Name} + +Outputs: + # Output the function names that were generated + GeneratedFunctions: + Description: List of generated function names + Value: + Fn::Join: + - ", " + - !Ref FunctionNames diff --git a/tests/integration/testdata/buildcmd/language-extensions.yaml b/tests/integration/testdata/buildcmd/language-extensions.yaml index db3b791f6e6..61309e9e51d 100644 --- a/tests/integration/testdata/buildcmd/language-extensions.yaml +++ b/tests/integration/testdata/buildcmd/language-extensions.yaml @@ -1,26 +1,115 @@ -AWSTemplateFormatVersion : '2010-09-09' +AWSTemplateFormatVersion: '2010-09-09' Transform: - AWS::LanguageExtensions - AWS::Serverless-2016-10-31 +Description: > + Test template for CloudFormation Language Extensions integration. + Demonstrates Fn::ForEach usage for generating multiple Lambda functions, + and conditional DeletionPolicy/UpdateReplacePolicy support. + Parameters: + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions Environment: Type: String Default: dev AllowedValues: - dev - prod + Description: Deployment environment Conditions: - IsProd: !Equals [!Ref Environment, prod] + IsProd: !Equals [!Ref Environment, "prod"] Globals: Function: Timeout: 20 MemorySize: 512 + Runtime: !Ref Runtime Resources: - Bucket: - Type: AWS::S3::Bucket - DeletionPolicy: !If [ IsProd, Retain, Delete ] - UpdateReplacePolicy: !If [ IsProd, Retain, Delete ] + # Test Fn::ForEach generating multiple Lambda functions (Requirement 17.1) + # with conditional DeletionPolicy and UpdateReplacePolicy + # This generates AlphaFunction and BetaFunction + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + DeletionPolicy: !If [IsProd, Retain, Delete] + UpdateReplacePolicy: !If [IsProd, Retain, Delete] + Properties: + Handler: main.handler + CodeUri: PreBuiltPython + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + + # Test Fn::ToJsonString in environment variables (Requirement 17.2) + # with conditional DeletionPolicy and UpdateReplacePolicy + ConfigFunction: + Type: AWS::Serverless::Function + DeletionPolicy: !If [IsProd, Retain, Delete] + UpdateReplacePolicy: !If [IsProd, Retain, Delete] + Properties: + Handler: main.handler + CodeUri: PreBuiltPython + Environment: + Variables: + # Fn::ToJsonString converts the object to a JSON string + CONFIG_JSON: + Fn::ToJsonString: + setting1: value1 + setting2: value2 + nested: + key: nestedValue + + # Test Fn::Length for array operations (Requirement 17.3) + # Fn::Length returns the number of elements in an array + ArrayLengthFunction: + Type: AWS::Serverless::Function + DeletionPolicy: !If [IsProd, Retain, Delete] + UpdateReplacePolicy: !If [IsProd, Retain, Delete] + Properties: + Handler: main.handler + CodeUri: Python + Environment: + Variables: + # Fn::Length returns the count of items in the array + SERVICES_COUNT: + Fn::Length: + - Users + - Orders + - Products + - Inventory + # Fn::Length can also be used with Fn::ToJsonString + ARRAY_INFO: + Fn::ToJsonString: + services: + - Users + - Orders + - Products + - Inventory + count: + Fn::Length: + - Users + - Orders + - Products + - Inventory + + + # Fn::ForEach::Topics: + # - TopicName + # - - Success + # - Failure + # - Timeout + # - Unknown + # - 'SnsTopic${TopicName}': + # Type: 'AWS::SNS::Topic' + # Properties: + # TopicName: !Sub "SnsTopic${TopicName}.fifo" + # FifoTopic: true diff --git a/tests/integration/testdata/buildcmd/samconfig.toml b/tests/integration/testdata/buildcmd/samconfig.toml new file mode 100644 index 00000000000..e77ee46b491 --- /dev/null +++ b/tests/integration/testdata/buildcmd/samconfig.toml @@ -0,0 +1,47 @@ +version = 0.1 + +[default.deploy.parameters] +stack_name = "foreach-demo" +resolve_s3 = true +s3_prefix = "foreach-demo" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "Runtime=\"python3.13\" Environment=\"dev\"" +image_repositories = [] + +[default.global.parameters] +region = "us-west-2" + +[dev2.deploy.parameters] +stack_name = "foreach-topics-test" +resolve_s3 = true +s3_prefix = "foreach-topics-test" +region = "us-west-2" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "Runtime=\"python3.13\" Environment=\"dev2\"" +image_repositories = [] + +[dev2.global.parameters] +region = "us-west-2" + +[dev.deploy.parameters] +stack_name = "foreach-topics" +resolve_s3 = true +s3_prefix = "foreach-topics" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "Runtime=\"python3.13\" Environment=\"dev\"" +image_repositories = [] + +[dev.global.parameters] +region = "us-west-2" + +[topics.deploy.parameters] +stack_name = "foreach-topics-test" +resolve_s3 = true +s3_prefix = "foreach-topics-test" +region = "us-west-2" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "Runtime=\"python3.13\" Environment=\"dev\"" +image_repositories = [] + +[topics.global.parameters] +region = "us-west-2" diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index e7adaa9adc3..5186a1f0808 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -1454,3 +1454,451 @@ def test_check_build_method_experimental_flag_no_metadata(self, mock_get_resourc self.build_context._check_build_method_experimental_flag() mock_prompt.assert_not_called() + + +class TestBuildContext_get_template_for_output(TestCase): + """Tests for the _get_template_for_output method that handles original template preservation.""" + + def setUp(self): + self.build_context = BuildContext( + resource_identifier="function_identifier", + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + parallel=False, + mode="mode", + cached=False, + ) + + def test_returns_modified_template_when_no_original_template(self): + """When stack has no original_template_dict, return the modified template.""" + stack = Mock() + stack.original_template_dict = None + modified_template = {"Resources": {"Function": {"Type": "AWS::Serverless::Function"}}} + artifacts = {} + + result = self.build_context._get_template_for_output(stack, modified_template, artifacts) + + self.assertEqual(result, modified_template) + + def test_returns_modified_template_when_original_template_is_not_dict(self): + """When stack.original_template_dict is not a dict (e.g., Mock), return the modified template.""" + stack = Mock() + # Mock objects are not dicts, so this should return modified_template + modified_template = {"Resources": {"Function": {"Type": "AWS::Serverless::Function"}}} + artifacts = {} + + result = self.build_context._get_template_for_output(stack, modified_template, artifacts) + + self.assertEqual(result, modified_template) + + def test_returns_original_template_when_present(self): + """When stack has original_template_dict, return a copy of it with updated paths.""" + stack = Mock() + stack.location = "/path/to/template.yaml" + original_template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "${Name}.handler", + }, + } + }, + ] + } + } + stack.original_template_dict = original_template + modified_template = { + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "../build/AlphaFunction", + "Handler": "Alpha.handler", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "../build/BetaFunction", + "Handler": "Beta.handler", + }, + }, + } + } + artifacts = {} + + result = self.build_context._get_template_for_output(stack, modified_template, artifacts) + + # Should return the original template structure with Fn::ForEach + self.assertIn("Fn::ForEach::Functions", result.get("Resources", {})) + # The CodeUri should be updated from the modified template + foreach_body = result["Resources"]["Fn::ForEach::Functions"][2] + self.assertEqual( + foreach_body["${Name}Function"]["Properties"]["CodeUri"], + "../build/AlphaFunction", # Updated from modified template + ) + + def test_original_template_is_deep_copied(self): + """Ensure the original template is deep copied and not modified in place.""" + stack = Mock() + stack.location = "/path/to/template.yaml" + original_template = { + "Resources": { + "Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + }, + } + } + } + stack.original_template_dict = original_template + modified_template = { + "Resources": { + "Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "../build/Function", + }, + } + } + } + artifacts = {} + + result = self.build_context._get_template_for_output(stack, modified_template, artifacts) + + # Original template should not be modified + self.assertEqual(original_template["Resources"]["Function"]["Properties"]["CodeUri"], "./src") + # Result should have updated path + self.assertEqual(result["Resources"]["Function"]["Properties"]["CodeUri"], "../build/Function") + + def test_dynamic_codeuri_generates_mappings_in_output(self): + """When Fn::ForEach has dynamic CodeUri, the output template should have Mappings.""" + stack = Mock() + stack.location = "/path/to/template.yaml" + original_template = { + "Resources": { + "Fn::ForEach::Functions": [ + "FunctionName", + ["Alpha", "Beta"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./${FunctionName}", + "Handler": "index.handler", + }, + } + }, + ] + } + } + stack.original_template_dict = original_template + modified_template = { + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "AlphaFunction", + "Handler": "index.handler", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "BetaFunction", + "Handler": "index.handler", + }, + }, + } + } + artifacts = {} + + result = self.build_context._get_template_for_output(stack, modified_template, artifacts) + + # Should have Mappings section + self.assertIn("Mappings", result) + self.assertIn("SAMCodeUriFunctions", result["Mappings"]) + self.assertEqual(result["Mappings"]["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], "AlphaFunction") + self.assertEqual(result["Mappings"]["SAMCodeUriFunctions"]["Beta"]["CodeUri"], "BetaFunction") + + # CodeUri should be Fn::FindInMap + foreach_body = result["Resources"]["Fn::ForEach::Functions"][2] + code_uri = foreach_body["${FunctionName}Function"]["Properties"]["CodeUri"] + self.assertIsInstance(code_uri, dict) + self.assertEqual(code_uri["Fn::FindInMap"], ["SAMCodeUriFunctions", {"Ref": "FunctionName"}, "CodeUri"]) + + +class TestBuildContext_update_foreach_artifact_paths(TestCase): + """Tests for the _update_foreach_artifact_paths method.""" + + def setUp(self): + self.build_context = BuildContext( + resource_identifier="function_identifier", + template_file="template_file", + base_dir="base_dir", + build_dir="build_dir", + cache_dir="cache_dir", + parallel=False, + mode="mode", + cached=False, + ) + + def test_updates_static_codeuri_in_foreach_body(self): + """Test that static CodeUri is updated in Fn::ForEach body from expanded resources.""" + import pathlib + + foreach_key = "Fn::ForEach::Functions" + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "${Name}.handler", + }, + } + }, + ] + modified_resources = { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "../build/AlphaFunction", + "Handler": "Alpha.handler", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "../build/BetaFunction", + "Handler": "Beta.handler", + }, + }, + } + original_dir = pathlib.Path("/path/to") + + mappings = self.build_context._update_foreach_artifact_paths( + foreach_key, foreach_value, modified_resources, original_dir + ) + + # Static CodeUri: should be updated to first matching expanded resource's path + self.assertEqual(foreach_value[2]["${Name}Function"]["Properties"]["CodeUri"], "../build/AlphaFunction") + # No Mappings should be generated for static properties + self.assertEqual(mappings, {}) + + def test_dynamic_codeuri_generates_mappings(self): + """Test that dynamic CodeUri generates Mappings with per-function build paths.""" + import pathlib + + foreach_key = "Fn::ForEach::Functions" + foreach_value = [ + "FunctionName", + ["Alpha", "Beta"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./${FunctionName}", + "Handler": "index.handler", + }, + } + }, + ] + modified_resources = { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "AlphaFunction", + "Handler": "index.handler", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "BetaFunction", + "Handler": "index.handler", + }, + }, + } + original_dir = pathlib.Path("/path/to") + + mappings = self.build_context._update_foreach_artifact_paths( + foreach_key, foreach_value, modified_resources, original_dir + ) + + # Mappings should be generated + self.assertIn("SAMCodeUriFunctions", mappings) + self.assertEqual(mappings["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], "AlphaFunction") + self.assertEqual(mappings["SAMCodeUriFunctions"]["Beta"]["CodeUri"], "BetaFunction") + + # CodeUri should be replaced with Fn::FindInMap + code_uri = foreach_value[2]["${FunctionName}Function"]["Properties"]["CodeUri"] + self.assertIsInstance(code_uri, dict) + self.assertIn("Fn::FindInMap", code_uri) + self.assertEqual(code_uri["Fn::FindInMap"], ["SAMCodeUriFunctions", {"Ref": "FunctionName"}, "CodeUri"]) + + # Fn::ForEach structure should be preserved + self.assertEqual(foreach_value[0], "FunctionName") + self.assertEqual(foreach_value[1], ["Alpha", "Beta"]) + + def test_handles_invalid_foreach_structure(self): + """Test that invalid Fn::ForEach structures are handled gracefully.""" + import pathlib + + foreach_key = "Fn::ForEach::Functions" + modified_resources = {} + original_dir = pathlib.Path("/path/to") + + # Invalid structure - not a list + result = self.build_context._update_foreach_artifact_paths( + foreach_key, "invalid", modified_resources, original_dir + ) + self.assertEqual(result, {}) + + # Invalid structure - list too short + result = self.build_context._update_foreach_artifact_paths( + foreach_key, ["Name", ["Alpha"]], modified_resources, original_dir + ) + self.assertEqual(result, {}) + + def test_handles_layer_resources(self): + """Test that layer ContentUri is updated in Fn::ForEach body.""" + import pathlib + + foreach_key = "Fn::ForEach::Layers" + foreach_value = [ + "Name", + ["Layer1", "Layer2"], + { + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./layer", + }, + } + }, + ] + modified_resources = { + "Layer1Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "../build/Layer1Layer", + }, + } + } + original_dir = pathlib.Path("/path/to") + + mappings = self.build_context._update_foreach_artifact_paths( + foreach_key, foreach_value, modified_resources, original_dir + ) + + self.assertEqual(foreach_value[2]["${Name}Layer"]["Properties"]["ContentUri"], "../build/Layer1Layer") + self.assertEqual(mappings, {}) + + def test_dynamic_codeuri_with_fn_sub(self): + """Test that dynamic CodeUri using Fn::Sub generates Mappings.""" + import pathlib + + foreach_key = "Fn::ForEach::Services" + foreach_value = [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": "./services/${Name}"}, + "Handler": "index.handler", + }, + } + }, + ] + modified_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "UsersService", + "Handler": "index.handler", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "OrdersService", + "Handler": "index.handler", + }, + }, + } + original_dir = pathlib.Path("/path/to") + + mappings = self.build_context._update_foreach_artifact_paths( + foreach_key, foreach_value, modified_resources, original_dir + ) + + self.assertIn("SAMCodeUriServices", mappings) + self.assertEqual(mappings["SAMCodeUriServices"]["Users"]["CodeUri"], "UsersService") + self.assertEqual(mappings["SAMCodeUriServices"]["Orders"]["CodeUri"], "OrdersService") + + +class TestBuildContext_contains_loop_variable(TestCase): + """Tests for the contains_loop_variable shared function.""" + + def test_string_with_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable("./${Name}", "Name")) + + def test_string_without_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertFalse(contains_loop_variable("./src", "Name")) + + def test_fn_sub_with_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Fn::Sub": "./${Name}"}, "Name")) + + def test_fn_sub_list_with_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Fn::Sub": ["./${Name}", {}]}, "Name")) + + def test_nested_dict_with_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"key": "./${Name}"}, "Name")) + + def test_list_with_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable(["./${Name}"], "Name")) + + def test_non_string_value(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertFalse(contains_loop_variable(42, "Name")) + + +class TestBuildContext_substitute_loop_variable(TestCase): + """Tests for the substitute_loop_variable shared function.""" + + def test_substitutes_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import substitute_loop_variable + + self.assertEqual(substitute_loop_variable("${Name}Function", "Name", "Alpha"), "AlphaFunction") + + def test_no_variable_present(self): + from samcli.lib.cfn_language_extensions.sam_integration import substitute_loop_variable + + self.assertEqual(substitute_loop_variable("StaticFunction", "Name", "Alpha"), "StaticFunction") diff --git a/tests/unit/commands/buildcmd/test_build_context_language_extensions.py b/tests/unit/commands/buildcmd/test_build_context_language_extensions.py new file mode 100644 index 00000000000..5673b6319bc --- /dev/null +++ b/tests/unit/commands/buildcmd/test_build_context_language_extensions.py @@ -0,0 +1,91 @@ +""" +Tests for BuildContext language extensions support. + +Covers _copy_artifact_paths for various resource types. +""" + +from unittest import TestCase +from unittest.mock import patch, MagicMock, PropertyMock + +from samcli.commands.build.build_context import BuildContext + + +class TestCopyArtifactPaths(TestCase): + """Tests for _copy_artifact_paths.""" + + def _make_context(self): + with patch.object(BuildContext, "__init__", lambda self: None): + return BuildContext() + + def test_serverless_function_codeuri(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./src"}} + modified = {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": ".aws-sam/build/Func"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["CodeUri"], ".aws-sam/build/Func") + + def test_serverless_function_imageuri(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::Function", "Properties": {"PackageType": "Image"}} + modified = { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "123.dkr.ecr.us-east-1.amazonaws.com/repo"}, + } + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["ImageUri"], "123.dkr.ecr.us-east-1.amazonaws.com/repo") + + def test_lambda_function_code(self): + ctx = self._make_context() + original = {"Type": "AWS::Lambda::Function", "Properties": {"Code": {"ZipFile": "code"}}} + modified = {"Type": "AWS::Lambda::Function", "Properties": {"Code": {"S3Bucket": "b", "S3Key": "k"}}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["Code"]["S3Bucket"], "b") + + def test_serverless_layer_contenturi(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::LayerVersion", "Properties": {"ContentUri": "./layer"}} + modified = {"Type": "AWS::Serverless::LayerVersion", "Properties": {"ContentUri": ".aws-sam/build/Layer"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["ContentUri"], ".aws-sam/build/Layer") + + def test_lambda_layer_content(self): + ctx = self._make_context() + original = {"Type": "AWS::Lambda::LayerVersion", "Properties": {"Content": {"S3Bucket": "old"}}} + modified = {"Type": "AWS::Lambda::LayerVersion", "Properties": {"Content": {"S3Bucket": "new"}}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["Content"]["S3Bucket"], "new") + + def test_serverless_api_definitionuri(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::Api", "Properties": {"DefinitionUri": "./api.yaml"}} + modified = {"Type": "AWS::Serverless::Api", "Properties": {"DefinitionUri": ".aws-sam/build/api.yaml"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["DefinitionUri"], ".aws-sam/build/api.yaml") + + def test_serverless_httpapi_definitionuri(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::HttpApi", "Properties": {"DefinitionUri": "./api.yaml"}} + modified = {"Type": "AWS::Serverless::HttpApi", "Properties": {"DefinitionUri": ".aws-sam/build/api.yaml"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["DefinitionUri"], ".aws-sam/build/api.yaml") + + def test_serverless_statemachine_definitionuri(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::StateMachine", "Properties": {"DefinitionUri": "./sm.json"}} + modified = {"Type": "AWS::Serverless::StateMachine", "Properties": {"DefinitionUri": ".aws-sam/build/sm.json"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["DefinitionUri"], ".aws-sam/build/sm.json") + + def test_unknown_resource_type_no_copy(self): + ctx = self._make_context() + original = {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": "test"}} + modified = {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": "modified"}} + ctx._copy_artifact_paths(original, modified) + self.assertEqual(original["Properties"]["TopicName"], "test") + + def test_no_matching_property_no_copy(self): + ctx = self._make_context() + original = {"Type": "AWS::Serverless::Function", "Properties": {"Handler": "main.handler"}} + modified = {"Type": "AWS::Serverless::Function", "Properties": {"Handler": "main.handler"}} + ctx._copy_artifact_paths(original, modified) + self.assertNotIn("CodeUri", original["Properties"]) From 57276d34448f3fcd2a5b0c0824212fda380fb272 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 13 Feb 2026 23:38:07 +0000 Subject: [PATCH 16/21] feat: add language extensions support to sam package and deploy Package: - _export() calls expand_language_extensions() for Phase 1 - Preserves Fn::ForEach in packaged template with S3 URIs - Generates Mappings for dynamic artifact properties - _find_artifact_uri_for_resource() handles all export formats: string, {S3Bucket,S3Key}, {Bucket,Key}, {ImageUri} - Recursive nested Fn::ForEach support - Warning for parameter-based collections Deploy: - Uploads original unexpanded template to CloudFormation - Clear error for missing Mapping keys Integration tests for CodeUri, ContentUri, DefinitionUri, ImageUri, BodyS3Location across all packageable resource types. --- samcli/commands/deploy/exceptions.py | 80 + samcli/commands/package/exceptions.py | 36 + samcli/commands/package/package_context.py | 708 ++- samcli/lib/deploy/deployer.py | 38 +- .../integration/deploy/test_deploy_command.py | 37 + ...est_package_command_language_extensions.py | 1590 ++++++ .../Alpha/main.py | 12 + .../Beta/main.py | 12 + .../template.yaml | 48 + .../function/index.py | 9 + .../layers/Common/python/common_utils.py | 16 + .../layers/Utils/python/utils.py | 19 + .../template.yaml | 53 + .../api/Orders.yaml | 46 + .../api/Users.yaml | 44 + .../functions/index.py | 14 + .../template.yaml | 51 + .../statemachines/Notify.asl.json | 27 + .../statemachines/Process.asl.json | 27 + .../template.yaml | 57 + .../template.yaml | 36 + .../src/__init__.py | 1 + .../language-extensions-foreach/src/main.py | 16 + .../language-extensions-foreach/template.yaml | 46 + .../order.service/main.py | 12 + .../template.yaml | 52 + .../user-service/main.py | 12 + .../services/Orders/main.py | 2 + .../services/Users/main.py | 2 + .../template.yaml | 23 + .../Orders/main.py | 12 + .../Users/main.py | 12 + .../template.yaml | 54 + .../commands/deploy/test_deploy_context.py | 164 + tests/unit/commands/deploy/test_exceptions.py | 159 + .../commands/package/test_package_context.py | 4516 +++++++++++++++++ ...est_package_context_language_extensions.py | 823 +++ tests/unit/lib/deploy/test_deployer.py | 59 + 38 files changed, 8919 insertions(+), 6 deletions(-) create mode 100644 tests/integration/package/test_package_command_language_extensions.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-codeuri/Alpha/main.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-codeuri/Beta/main.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-codeuri/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-contenturi/function/index.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Common/python/common_utils.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Utils/python/utils.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-contenturi/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Orders.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Users.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/functions/index.py create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Notify.asl.json create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Process.asl.json create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-dynamic-imageuri/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-foreach/src/__init__.py create mode 100644 tests/integration/testdata/package/language-extensions-foreach/src/main.py create mode 100644 tests/integration/testdata/package/language-extensions-foreach/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-invalid-mapping-keys/order.service/main.py create mode 100644 tests/integration/testdata/package/language-extensions-invalid-mapping-keys/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-invalid-mapping-keys/user-service/main.py create mode 100644 tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py create mode 100644 tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py create mode 100644 tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/template.yaml create mode 100644 tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Orders/main.py create mode 100644 tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Users/main.py create mode 100644 tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/template.yaml create mode 100644 tests/unit/commands/deploy/test_exceptions.py create mode 100644 tests/unit/commands/package/test_package_context_language_extensions.py diff --git a/samcli/commands/deploy/exceptions.py b/samcli/commands/deploy/exceptions.py index 2cbbcd5b42e..2cc8e1c2bb6 100644 --- a/samcli/commands/deploy/exceptions.py +++ b/samcli/commands/deploy/exceptions.py @@ -2,8 +2,46 @@ Exceptions that are raised by sam deploy """ +import re +from typing import Optional, Tuple + from samcli.commands.exceptions import UserException +# Pattern to match CloudFormation's Fn::FindInMap error message +# Example: "Fn::FindInMap - Key 'Products' not found in Mapping 'SAMCodeUriServices'" +# The pattern handles: +# - Single quotes: Key 'Products' +# - Double quotes: Key "Products" +# - No quotes: Key Products +FINDMAP_KEY_NOT_FOUND_PATTERN = re.compile( + r"Fn::FindInMap.*Key\s+" + r"(?:'([^']+)'|\"([^\"]+)\"|(\S+))" + r"\s+not found in Mapping\s+" + r"(?:'([^']+)'|\"([^\"]+)\"|(\S+))" +) + + +def parse_findmap_error(error_message: str) -> Optional[Tuple[str, str]]: + """ + Parse a CloudFormation error message to extract missing Mapping key information. + + Args: + error_message: The error message from CloudFormation + + Returns: + A tuple of (missing_key, mapping_name) if the error is a FindInMap key not found error, + None otherwise. + """ + match = FINDMAP_KEY_NOT_FOUND_PATTERN.search(error_message) + if match: + # Groups 1, 2, 3 are for the key (single-quoted, double-quoted, unquoted) + # Groups 4, 5, 6 are for the mapping name (single-quoted, double-quoted, unquoted) + key = match.group(1) or match.group(2) or match.group(3) + mapping = match.group(4) or match.group(5) or match.group(6) + if key and mapping: + return (key, mapping) + return None + class ChangeEmptyError(UserException): def __init__(self, stack_name): @@ -81,3 +119,45 @@ class DeployStackStatusMissingError(UserException): def __init__(self, stack_name): message_fmt = "Was not able to find a stack with the name: {msg}, please check your parameters and try again." super().__init__(message=message_fmt.format(msg=stack_name)) + + +class MissingMappingKeyError(UserException): + """ + Error raised when CloudFormation deployment fails due to a missing key in a Mapping. + + This typically occurs when a template was packaged with certain parameter values + (e.g., ServiceNames="Users,Orders") but deployed with different values + (e.g., ServiceNames="Users,Orders,Products"). The Mappings generated during + packaging only contain entries for the values known at package time. + """ + + def __init__(self, stack_name: str, missing_key: str, mapping_name: str, original_error: str): + self.stack_name = stack_name + self.missing_key = missing_key + self.mapping_name = mapping_name + self.original_error = original_error + + message = f"""Failed to create/update the stack: {stack_name} + +Error: Fn::FindInMap - Key '{missing_key}' not found in Mapping '{mapping_name}' + +This error typically occurs when: + - The template was packaged with certain parameter values + - You are deploying with different parameter values that include '{missing_key}' + - The Mappings generated during packaging don't include an entry for '{missing_key}' + +To fix this issue: + 1. Re-run 'sam package' with the same parameter values you want to use for deployment + 2. Then run 'sam deploy' with those same parameter values + +Example: + sam package --parameter-overrides YourParamName="value1,value2,{missing_key}" ... + sam deploy --parameter-overrides YourParamName="value1,value2,{missing_key}" ... + +Note: When using Fn::ForEach with dynamic artifact properties (like CodeUri: ./services/${{Name}}), +the collection values are fixed at package time. Any new values added at deploy time +will not have corresponding artifacts in S3. + +Original CloudFormation error: {original_error}""" + + super().__init__(message=message) diff --git a/samcli/commands/package/exceptions.py b/samcli/commands/package/exceptions.py index a7819601172..f696f785602 100644 --- a/samcli/commands/package/exceptions.py +++ b/samcli/commands/package/exceptions.py @@ -163,3 +163,39 @@ def __init__(self): message_fmt = "Cannot skip both --resolve-s3 and --s3-bucket parameters. Please provide one of these arguments." super().__init__(message=message_fmt) + + +class InvalidMappingKeyError(UserException): + """ + Exception raised when collection values contain invalid characters for CloudFormation Mapping keys. + + CloudFormation Mapping keys must contain only alphanumeric characters (a-z, A-Z, 0-9), + hyphens (-), and underscores (_). + """ + + def __init__(self, foreach_key, loop_name, invalid_values): + self.foreach_key = foreach_key + self.loop_name = loop_name + self.invalid_values = invalid_values + + # Format the invalid values for display + invalid_values_str = ", ".join(f'"{v}"' for v in invalid_values) + + message_fmt = ( + "Invalid collection values for CloudFormation Mapping keys in Fn::ForEach '{loop_name}'.\n" + "\n" + "The following collection values contain invalid characters: {invalid_values}\n" + "\n" + "CloudFormation Mapping keys can only contain alphanumeric characters (a-z, A-Z, 0-9), " + "hyphens (-), and underscores (_).\n" + "\n" + "Please update your collection values to use only valid characters.\n" + "For example, use 'user-service' instead of 'user.service' or 'user/service'." + ) + + super().__init__( + message=message_fmt.format( + loop_name=self.loop_name, + invalid_values=invalid_values_str, + ) + ) diff --git a/samcli/commands/package/package_context.py b/samcli/commands/package/package_context.py index 1a2a432ba01..325925ab4a4 100644 --- a/samcli/commands/package/package_context.py +++ b/samcli/commands/package/package_context.py @@ -14,16 +14,20 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import copy import json import logging import os -from typing import List, Optional +import re +from typing import Any, Dict, List, Optional, Tuple import boto3 import click +from samcli.commands._utils.template import FOREACH_REQUIRED_ELEMENTS from samcli.commands.package.exceptions import PackageFailedError from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack +from samcli.lib.cfn_language_extensions.sam_integration import substitute_loop_variable from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.package.artifact_exporter import Template from samcli.lib.package.code_signer import CodeSigner @@ -32,10 +36,11 @@ from samcli.lib.package.uploaders import Uploaders from samcli.lib.providers.provider import ResourceIdentifier, Stack, get_resource_full_path_by_id from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samcli.lib.samlib.wrapper import DynamicArtifactProperty from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent from samcli.lib.utils.preview_runtimes import PREVIEW_RUNTIMES from samcli.lib.utils.resources import AWS_LAMBDA_FUNCTION, AWS_SERVERLESS_FUNCTION -from samcli.yamlhelper import yaml_dump +from samcli.yamlhelper import yaml_dump, yaml_parse LOG = logging.getLogger(__name__) @@ -165,6 +170,32 @@ def run(self): raise PackageFailedError(template_file=self.template_file, ex=str(ex)) from ex def _export(self, template_path, use_json): + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + + # Read the original template + with open(template_path, "r") as f: + original_template_dict = yaml_parse(f.read()) + + # Build combined parameter values for expand_language_extensions + parameter_values = {} + parameter_values.update(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES) + if self.parameter_overrides: + parameter_values.update(self.parameter_overrides) + if self._global_parameter_overrides: + parameter_values.update(self._global_parameter_overrides) + + # Use the canonical expand_language_extensions() entry point (Phase 1) + try: + result = expand_language_extensions(original_template_dict, parameter_values, template_path=template_path) + except InvalidSamDocumentException as e: + raise PackageFailedError(template_file=self.template_file, ex=str(e)) from e + + uses_language_extensions = result.had_language_extensions + dynamic_properties = result.dynamic_artifact_properties + template_dict_for_export = result.expanded_template + + # Create Template with the (possibly expanded) template template = Template( template_path, os.getcwd(), @@ -172,16 +203,685 @@ def _export(self, template_path, use_json): self.code_signer, normalize_template=True, normalize_parameters=True, + template_str=yaml_dump(template_dict_for_export), ) + # Set template_dir since we're using template_str + template.template_dir = os.path.dirname(os.path.abspath(template_path)) + template.code_signer = self.code_signer + exported_template = template.export() + # If using language extensions, we need to preserve the original Fn::ForEach structure + # but update the artifact URIs (CodeUri, ContentUri, etc.) with the S3 locations + if uses_language_extensions: + LOG.debug("Template uses language extensions, preserving Fn::ForEach structure") + output_template = self._update_original_template_with_s3_uris( + result.original_template, exported_template, dynamic_properties + ) + + # Generate Mappings for dynamic artifact properties + if dynamic_properties: + LOG.debug("Generating Mappings for %d dynamic artifact properties", len(dynamic_properties)) + + # Emit warning for parameter-based collections with dynamic artifact properties + self._warn_parameter_based_collections(dynamic_properties) + + template_dir = os.path.dirname(os.path.abspath(template_path)) + exported_resources = exported_template.get("Resources", {}) + + mappings, _ = self._generate_artifact_mappings(dynamic_properties, template_dir, exported_resources) + + # Apply Mappings to the output template + output_template = self._apply_artifact_mappings_to_template( + output_template, mappings, dynamic_properties + ) + else: + output_template = exported_template + if use_json: - exported_str = json.dumps(exported_template, indent=4, ensure_ascii=False) + exported_str = json.dumps(output_template, indent=4, ensure_ascii=False) else: - exported_str = yaml_dump(exported_template) + exported_str = yaml_dump(output_template) return exported_str + def _generate_artifact_mappings( + self, + dynamic_properties: List[DynamicArtifactProperty], + template_dir: str, + exported_resources: Dict[str, Any], + ) -> Tuple[Dict[str, Dict[str, Dict[str, str]]], Dict[Tuple[str, str], str]]: + """ + Generate Mappings section for dynamic artifact properties in Fn::ForEach blocks. + + For each dynamic artifact property, this method: + 1. Resolves paths for all collection values + 2. Finds the corresponding S3 URI from the exported (expanded) resources + 3. Generates a Mappings section with format SAM{PropertyName}{LoopName} + + Parameters + ---------- + dynamic_properties : List[DynamicArtifactProperty] + List of dynamic artifact properties detected in Fn::ForEach blocks + template_dir : str + The directory containing the template (for resolving relative paths) + exported_resources : Dict[str, Any] + The exported resources with S3 URIs from the expanded template + + Returns + ------- + Tuple[Dict[str, Dict[str, Dict[str, str]]], Dict[Tuple[str, str], str]] + A tuple containing: + - The generated Mappings section + - A dict mapping (foreach_key, property_name) to the Mapping name for replacement + """ + mappings: Dict[str, Dict[str, Dict[str, str]]] = {} + property_to_mapping: Dict[Tuple[str, str], str] = {} + + for prop in dynamic_properties: + # Validate collection values are valid CloudFormation Mapping keys + self._validate_mapping_key_compatibility(prop) + + # Generate Mapping name: SAM{PropertyName}{LoopName} + mapping_name = f"SAM{prop.property_name}{prop.loop_name}" + + # Initialize the mapping if not exists + if mapping_name not in mappings: + mappings[mapping_name] = {} + + # Determine if compound keys are needed (property references outer loop variables) + uses_outer_vars = False + referenced_outer_loops: List[Tuple[str, str, List[str]]] = [] + if prop.outer_loops: + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + for outer_key, outer_var, outer_coll in prop.outer_loops: + if contains_loop_variable(prop.property_value, outer_var): + uses_outer_vars = True + referenced_outer_loops.append((outer_key, outer_var, outer_coll)) + + if uses_outer_vars and referenced_outer_loops: + # Compound keys: enumerate all combinations of outer and inner values + import itertools + + outer_collections = [ol[2] for ol in referenced_outer_loops] + outer_vars = [ol[1] for ol in referenced_outer_loops] + + for combo in itertools.product(*outer_collections, prop.collection): + outer_values = list(combo[:-1]) + inner_value = combo[-1] + compound_key = "-".join(list(outer_values) + [inner_value]) + + # Build the expanded resource key by substituting all loop variables + expanded_resource_key = prop.resource_key + for outer_var, outer_val in zip(outer_vars, outer_values): + expanded_resource_key = substitute_loop_variable(expanded_resource_key, outer_var, outer_val) + expanded_resource_key = substitute_loop_variable( + expanded_resource_key, prop.loop_variable, inner_value + ) + + s3_uri = self._find_artifact_uri_for_resource( + exported_resources, expanded_resource_key, prop.resource_type, prop.property_name + ) + + if s3_uri: + mappings[mapping_name][compound_key] = {prop.property_name: s3_uri} + else: + LOG.warning( + "Could not find S3 URI for %s in expanded resource %s", + prop.property_name, + expanded_resource_key, + ) + else: + # Simple keys: inner collection values only + for collection_value in prop.collection: + # For nested ForEach, the expanded resource key has outer variables + # already substituted by CloudFormation. We need to find any matching + # expanded resource that has this inner collection value substituted. + expanded_resource_key = prop.resource_key + + # If there are outer loops, we need to substitute outer variables too + # to find the expanded resource. Use the first value from each outer collection. + if prop.outer_loops: + for _, outer_var, outer_coll in prop.outer_loops: + if outer_coll: + expanded_resource_key = substitute_loop_variable( + expanded_resource_key, outer_var, outer_coll[0] + ) + + expanded_resource_key = substitute_loop_variable( + expanded_resource_key, prop.loop_variable, collection_value + ) + + # Find the artifact URI from the exported resources + s3_uri = self._find_artifact_uri_for_resource( + exported_resources, expanded_resource_key, prop.resource_type, prop.property_name + ) + + if s3_uri: + mappings[mapping_name][collection_value] = {prop.property_name: s3_uri} + else: + LOG.warning( + "Could not find S3 URI for %s in expanded resource %s", + prop.property_name, + expanded_resource_key, + ) + + # Record the mapping for this property + property_to_mapping[(prop.foreach_key, prop.property_name)] = mapping_name + + return mappings, property_to_mapping + + def _validate_mapping_key_compatibility(self, prop: DynamicArtifactProperty) -> None: + """ + Validate that collection values are valid CloudFormation Mapping keys. + + CloudFormation Mapping keys must contain only alphanumeric characters (a-z, A-Z, 0-9), + hyphens (-), and underscores (_). This method checks all collection values and raises + an error if any contain invalid characters. + + Parameters + ---------- + prop : DynamicArtifactProperty + The dynamic artifact property containing the collection to validate + + Raises + ------ + InvalidMappingKeyError + If any collection value contains invalid characters for CloudFormation Mapping keys + """ + from samcli.commands.package.exceptions import InvalidMappingKeyError + + # Pattern for valid CloudFormation Mapping keys: alphanumeric, hyphens, and underscores + valid_key_pattern = re.compile(r"^[a-zA-Z0-9_-]+$") + + invalid_values = [] + for value in prop.collection: + if not valid_key_pattern.match(value): + invalid_values.append(value) + + if invalid_values: + raise InvalidMappingKeyError( + foreach_key=prop.foreach_key, + loop_name=prop.loop_name, + invalid_values=invalid_values, + ) + + def _warn_parameter_based_collections(self, dynamic_properties: List[DynamicArtifactProperty]) -> None: + """ + Emit warnings for dynamic artifact properties that use parameter-based collections. + + When a Fn::ForEach uses a parameter reference for its collection (e.g., !Ref ServiceNames) + AND has a dynamic artifact property (e.g., CodeUri: ./services/${Name}), the Mappings + are generated based on the parameter values at package time. If the user deploys with + different parameter values, the deploy will fail because the Mapping keys won't exist. + + This method emits a warning to inform users about this constraint. + + Parameters + ---------- + dynamic_properties : List[DynamicArtifactProperty] + List of dynamic artifact properties detected in Fn::ForEach blocks + """ + # Track which ForEach loops we've already warned about to avoid duplicate warnings + warned_loops: set = set() + + for prop in dynamic_properties: + if prop.collection_is_parameter_ref and prop.foreach_key not in warned_loops: + warned_loops.add(prop.foreach_key) + + # Extract the loop name for a cleaner message + loop_name = prop.loop_name + param_name = prop.collection_parameter_name or "parameter" + + warning_msg = ( + f"Warning: Fn::ForEach '{loop_name}' uses dynamic {prop.property_name} " + f"with a parameter-based collection (!Ref {param_name}). " + f"Collection values are fixed at package time. " + f"If you change the parameter value at deploy time, you must re-package first." + ) + + LOG.warning(warning_msg) + click.secho(warning_msg, fg="yellow") + + def _find_artifact_uri_for_resource( + self, + exported_resources: Dict[str, Any], + resource_key: str, + resource_type: str, + property_name: str, + ) -> Optional[str]: + """ + Find the artifact URI for a specific resource and property from the exported resources. + + This method handles all artifact property export formats: + - String format (S3 protocol URLs) for SAM resources like CodeUri, ContentUri, DefinitionUri + - {S3Bucket, S3Key} dict format for Lambda resources (Code, Content) + - {Bucket, Key} dict format for StateMachine and API Gateway resources + - {ImageUri} dict format for ECR container images + + Parameters + ---------- + exported_resources : Dict[str, Any] + The exported resources with artifact URIs (S3 or ECR) + resource_key : str + The resource logical ID to find + resource_type : str + The expected resource type + property_name : str + The property name to get the artifact URI from + + Returns + ------- + Optional[str] + The artifact URI if found, None otherwise + """ + resource = exported_resources.get(resource_key) + if not isinstance(resource, dict): + return None + + if resource.get("Type") != resource_type: + return None + + properties = resource.get("Properties", {}) + if not isinstance(properties, dict): + return None + + artifact_uri = properties.get(property_name) + + # Format 1: String URI (S3 protocol URL) + # Used by: AWS::Serverless::Function (CodeUri), AWS::Serverless::LayerVersion (ContentUri), + # AWS::Serverless::Api/HttpApi (DefinitionUri), AWS::Serverless::GraphQLApi (SchemaUri, CodeUri) + if isinstance(artifact_uri, str): + return artifact_uri + + # Handle dict formats + if isinstance(artifact_uri, dict): + # Format 2: {S3Bucket, S3Key} dict format + # Used by: AWS::Lambda::Function (Code), AWS::Lambda::LayerVersion (Content) + if "S3Bucket" in artifact_uri and "S3Key" in artifact_uri: + return f"s3://{artifact_uri['S3Bucket']}/{artifact_uri['S3Key']}" + + # Format 3: {Bucket, Key} dict format + # Used by: AWS::Serverless::StateMachine (DefinitionUri), + # AWS::ApiGateway::RestApi (BodyS3Location), AWS::ApiGatewayV2::Api (BodyS3Location), + # AWS::StepFunctions::StateMachine (DefinitionS3Location) + if "Bucket" in artifact_uri and "Key" in artifact_uri: + return f"s3://{artifact_uri['Bucket']}/{artifact_uri['Key']}" + + # Format 4: {ImageUri} dict format (ECR container images) + # Used by: AWS::Serverless::Function (ImageUri) after ECR upload + if "ImageUri" in artifact_uri: + image_uri = artifact_uri["ImageUri"] + return str(image_uri) if image_uri is not None else None + + return None + + def _apply_artifact_mappings_to_template( + self, + template: Dict[str, Any], + mappings: Dict[str, Dict[str, Dict[str, str]]], + dynamic_properties: List[DynamicArtifactProperty], + ) -> Dict[str, Any]: + """ + Apply the generated Mappings to the template and replace dynamic artifact properties. + + This method: + 1. Adds the generated Mappings section to the template + 2. Replaces dynamic artifact properties with Fn::FindInMap references + + Parameters + ---------- + template : Dict[str, Any] + The template to modify (will be modified in place) + mappings : Dict[str, Dict[str, Dict[str, str]]] + The generated Mappings section + dynamic_properties : List[DynamicArtifactProperty] + List of dynamic artifact properties to replace + + Returns + ------- + Dict[str, Any] + The modified template + """ + # Add Mappings section to template + if mappings: + if "Mappings" not in template: + template["Mappings"] = {} + template["Mappings"].update(mappings) + + # Replace dynamic artifact properties with Fn::FindInMap + resources = template.get("Resources", {}) + for prop in dynamic_properties: + self._replace_dynamic_artifact_with_findmap(resources, prop) + + return template + + def _replace_dynamic_artifact_with_findmap( + self, + resources: Dict[str, Any], + prop: DynamicArtifactProperty, + ) -> bool: + """ + Replace a dynamic artifact property value with Fn::FindInMap reference. + + This method replaces a dynamic artifact property (e.g., `CodeUri: ./services/${Name}`) + with a `Fn::FindInMap` reference that looks up the S3 URI from the generated + Mappings section. + + For nested Fn::ForEach blocks, traverses through the outer_loops chain to + locate the correct ForEach body containing the resource definition. + + Parameters + ---------- + resources : Dict[str, Any] + The Resources section of the template (will be modified in place) + prop : DynamicArtifactProperty + The dynamic artifact property to replace + + Returns + ------- + bool + True if the replacement was successful, False otherwise + """ + # Generate Mapping name: SAM{PropertyName}{LoopName} + mapping_name = f"SAM{prop.property_name}{prop.loop_name}" + + # For nested ForEach, traverse through outer loops to find the inner body + current_scope = resources + if prop.outer_loops: + for outer_key, _, _ in prop.outer_loops: + foreach_value = current_scope.get(outer_key) + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + LOG.warning("Could not traverse outer Fn::ForEach block %s", outer_key) + return False + body = foreach_value[2] + if not isinstance(body, dict): + LOG.warning("Outer Fn::ForEach body is not a dict for %s", outer_key) + return False + current_scope = body + + # Find the Fn::ForEach block and update the property + foreach_value = current_scope.get(prop.foreach_key) + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + LOG.warning( + "Could not find valid Fn::ForEach block for %s", + prop.foreach_key, + ) + return False + + body = foreach_value[2] + if not isinstance(body, dict): + LOG.warning( + "Fn::ForEach body is not a dict for %s", + prop.foreach_key, + ) + return False + + resource_def = body.get(prop.resource_key) + if not isinstance(resource_def, dict): + LOG.warning( + "Could not find resource definition for %s in %s", + prop.resource_key, + prop.foreach_key, + ) + return False + + properties = resource_def.get("Properties", {}) + if not isinstance(properties, dict): + LOG.warning( + "Properties is not a dict for resource %s in %s", + prop.resource_key, + prop.foreach_key, + ) + return False + + # Replace the property with Fn::FindInMap + # Use {"Ref": loop_variable} so ForEach substitutes the collection value + # into the FindInMap lookup (bare ${Var} strings are NOT resolved by + # ForEach inside FindInMap arguments) + properties[prop.property_name] = { + "Fn::FindInMap": [ + mapping_name, + {"Ref": prop.loop_variable}, + prop.property_name, + ] + } + + LOG.debug( + "Replaced %s in %s/%s with Fn::FindInMap reference to %s", + prop.property_name, + prop.foreach_key, + prop.resource_key, + mapping_name, + ) + + return True + + def _update_original_template_with_s3_uris( + self, + original_template: Dict[str, Any], + exported_template: Dict[str, Any], + dynamic_properties: Optional[List[DynamicArtifactProperty]] = None, + ) -> Dict[str, Any]: + """ + Update the original template (with Fn::ForEach intact) with S3 URIs from the exported template. + + For templates with language extensions, we preserve the original Fn::ForEach structure + but update artifact properties (CodeUri, ContentUri, etc.) with the S3 locations + from the exported (expanded) template. + + For dynamic artifact properties (those using loop variables), we skip updating them + here since they will be handled by the Mappings transformation. + + Parameters + ---------- + original_template : dict + The original template with Fn::ForEach constructs + exported_template : dict + The exported template with expanded resources and S3 URIs + dynamic_properties : Optional[List[DynamicArtifactProperty]] + List of dynamic artifact properties to skip (will be handled by Mappings) + + Returns + ------- + dict + The original template with updated S3 URIs + """ + result = copy.deepcopy(original_template) + + # Build a set of (foreach_key, property_name) tuples for dynamic properties + dynamic_prop_keys: set = set() + if dynamic_properties: + for prop in dynamic_properties: + dynamic_prop_keys.add((prop.foreach_key, prop.property_name)) + + # Copy non-Resources sections that may have been modified (e.g., Metadata, Fn::Transform) + # Preserve Outputs and Conditions from the original template because the + # language extensions processor partially resolves intrinsic functions + # (e.g., Fn::Sub resolves pseudo-parameters but leaves resource references + # as literal ${ResourceName} placeholders). This corrupts Fn::Sub expressions + # that reference Fn::ForEach-generated resources, turning them into plain + # strings that CloudFormation cannot resolve at deploy time. + sections_to_preserve = {"Resources", "Outputs", "Conditions"} + for key, value in exported_template.items(): + if key not in sections_to_preserve: + result[key] = copy.deepcopy(value) + + # Update Resources section, preserving Fn::ForEach structure + original_resources = result.get("Resources", {}) + exported_resources = exported_template.get("Resources", {}) + + self._update_resources_with_s3_uris(original_resources, exported_resources, dynamic_prop_keys) + + return result + + def _update_resources_with_s3_uris( + self, + original_resources: Dict[str, Any], + exported_resources: Dict[str, Any], + dynamic_prop_keys: Optional[set] = None, + ) -> None: + """ + Update resources in the original template with S3 URIs from the exported template. + + This method handles both regular resources and Fn::ForEach constructs. + + Parameters + ---------- + original_resources : dict + The original resources section (will be modified in place) + exported_resources : dict + The exported resources section with S3 URIs + dynamic_prop_keys : Optional[set] + Set of (foreach_key, property_name) tuples for dynamic properties to skip + """ + for resource_key, resource_value in original_resources.items(): + if resource_key.startswith("Fn::ForEach::"): + # Handle Fn::ForEach construct + self._update_foreach_with_s3_uris(resource_key, resource_value, exported_resources, dynamic_prop_keys) + elif isinstance(resource_value, dict) and resource_key in exported_resources: + # Regular resource - copy S3 URIs from exported template + exported_resource = exported_resources.get(resource_key, {}) + self._copy_artifact_uris(resource_value, exported_resource) + + def _update_foreach_with_s3_uris( + self, + foreach_key: str, + foreach_value: list, + exported_resources: Dict[str, Any], + dynamic_prop_keys: Optional[set] = None, + ) -> None: + """ + Update artifact URIs in a Fn::ForEach construct. + + For Fn::ForEach with static artifact properties (e.g., CodeUri: ./src), + all generated functions share the same S3 URI. We find one of the expanded + functions and use its S3 URI. + + For dynamic artifact properties, we skip updating them here since they + will be handled by the Mappings transformation. + + Parameters + ---------- + foreach_key : str + The Fn::ForEach key (e.g., "Fn::ForEach::Services") + foreach_value : list + The Fn::ForEach value [loop_var, collection, body] + exported_resources : dict + The exported resources with S3 URIs + dynamic_prop_keys : Optional[set] + Set of (foreach_key, property_name) tuples for dynamic properties to skip + """ + if not isinstance(foreach_value, list) or len(foreach_value) < FOREACH_REQUIRED_ELEMENTS: + return + + # Fn::ForEach structure: [loop_var, collection, body] + body = foreach_value[2] + + if not isinstance(body, dict): + return + + # The body contains resource definitions with ${loop_var} placeholders + for resource_template_key, resource_template in body.items(): + # Recurse into nested Fn::ForEach blocks + if isinstance(resource_template_key, str) and resource_template_key.startswith("Fn::ForEach::"): + self._update_foreach_with_s3_uris( + resource_template_key, resource_template, exported_resources, dynamic_prop_keys + ) + continue + + if not isinstance(resource_template, dict): + continue + + resource_type = resource_template.get("Type", "") + properties = resource_template.get("Properties", {}) + + # Find a matching expanded resource to get the S3 URI + for exported_key, exported_resource in exported_resources.items(): + if not isinstance(exported_resource, dict): + continue + + exported_type = exported_resource.get("Type", "") + if exported_type != resource_type: + continue + + exported_props = exported_resource.get("Properties", {}) + + # Copy artifact URIs from the expanded resource + # For static artifact properties, all expanded functions have the same S3 URI + # Skip dynamic properties (they will be handled by Mappings) + if self._copy_artifact_uris_for_type( + properties, exported_props, resource_type, foreach_key, dynamic_prop_keys + ): + break + + def _copy_artifact_uris(self, original_resource: Dict, exported_resource: Dict) -> None: + """ + Copy artifact URIs from exported resource to original resource. + + Parameters + ---------- + original_resource : dict + The original resource (will be modified in place) + exported_resource : dict + The exported resource with S3 URIs + """ + original_props = original_resource.get("Properties", {}) + exported_props = exported_resource.get("Properties", {}) + resource_type = original_resource.get("Type", "") + + self._copy_artifact_uris_for_type(original_props, exported_props, resource_type) + + def _copy_artifact_uris_for_type( + self, + original_props: Dict, + exported_props: Dict, + resource_type: str, + foreach_key: Optional[str] = None, + dynamic_prop_keys: Optional[set] = None, + ) -> bool: + """ + Copy artifact URIs based on resource type. + + Uses PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES to determine which + properties to copy, avoiding a long elif chain. + + Parameters + ---------- + original_props : dict + The original properties (will be modified in place) + exported_props : dict + The exported properties with S3 URIs + resource_type : str + The CloudFormation resource type + foreach_key : Optional[str] + The Fn::ForEach key (for checking dynamic properties) + dynamic_prop_keys : Optional[set] + Set of (foreach_key, property_name) tuples for dynamic properties to skip + + Returns + ------- + bool + True if any URI was copied, False otherwise + """ + from samcli.lib.samlib.wrapper import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES + + prop_names = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type) + if not prop_names: + return False + + copied = False + for prop_name in prop_names: + if prop_name not in exported_props: + continue + if dynamic_prop_keys and foreach_key and (foreach_key, prop_name) in dynamic_prop_keys: + continue + original_props[prop_name] = exported_props[prop_name] + copied = True + + return copied + @staticmethod def _warn_preview_runtime(stacks: List[Stack]) -> None: for stack in stacks: diff --git a/samcli/lib/deploy/deployer.py b/samcli/lib/deploy/deployer.py index e803b8b3b4c..3c25c50a08c 100644 --- a/samcli/lib/deploy/deployer.py +++ b/samcli/lib/deploy/deployer.py @@ -33,6 +33,8 @@ DeployFailedError, DeployStackOutPutFailedError, DeployStackStatusMissingError, + MissingMappingKeyError, + parse_findmap_error, ) from samcli.lib.deploy.utils import DeployColor, FailureMode from samcli.lib.package.local_files_utils import get_uploaded_s3_object_name, mktempfile @@ -95,6 +97,34 @@ def __init__(self, cloudformation_client, changeset_prefix="samcli-deploy", clie self.deploy_color = DeployColor() self._colored = Colored() + @staticmethod + def _create_deploy_error(stack_name: str, error_message: str) -> Exception: + """ + Create the appropriate deploy error based on the error message. + + This method checks if the error is a Fn::FindInMap key not found error + (which typically occurs when deploying with different parameter values + than were used during packaging) and returns a more helpful error message. + + Args: + stack_name: The name of the stack being deployed + error_message: The error message from CloudFormation + + Returns: + MissingMappingKeyError if the error is a FindInMap key not found error, + DeployFailedError otherwise. + """ + findmap_error = parse_findmap_error(error_message) + if findmap_error: + missing_key, mapping_name = findmap_error + return MissingMappingKeyError( + stack_name=stack_name, + missing_key=missing_key, + mapping_name=mapping_name, + original_error=error_message, + ) + return DeployFailedError(stack_name=stack_name, msg=error_message) + # pylint: disable=inconsistent-return-statements def has_stack(self, stack_name): """ @@ -544,7 +574,9 @@ def wait_for_execute( msg = self._gen_deploy_failed_with_rollback_disabled_msg(stack_name) LOG.info(self._colored.color_log(msg=msg, color=Colors.FAILURE), extra=dict(markup=True)) - raise deploy_exceptions.DeployFailedError(stack_name=stack_name, msg=str(ex)) + # Use the helper method to create the appropriate error + # This will detect Fn::FindInMap key not found errors and provide a more helpful message + raise self._create_deploy_error(stack_name, str(ex)) try: outputs = self.get_stack_outputs(stack_name=stack_name, echo=False) @@ -676,7 +708,9 @@ def sync( return result except botocore.exceptions.ClientError as ex: - raise DeployFailedError(stack_name=stack_name, msg=str(ex)) from ex + # Use the helper method to create the appropriate error + # This will detect Fn::FindInMap key not found errors and provide a more helpful message + raise self._create_deploy_error(stack_name, str(ex)) from ex @staticmethod @pprint_column_names( diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index 0ba2614ada9..d6874ffddfb 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -1786,3 +1786,40 @@ def test_deploy_lmi_function(self): deploy_process_execute = self.run_command(deploy_command_list) self.assertEqual(deploy_process_execute.process.returncode, 0) + + def test_deploy_with_language_extensions_dynamic_imageuri(self): + """ + Test that sam deploy works with Fn::ForEach and dynamic ImageUri. + + This test verifies: + 1. Deploy succeeds with dynamic ImageUri template + 2. CloudFormation correctly processes the Fn::ForEach with Mappings + 3. Both Alpha and Beta functions are created + + Validates Requirements: 12.1, 19.2 + """ + # Reuse the image already pulled in setUpClass (latest-x86_64) + # and tag it for the alpha/beta function names used by the template. + repo = "public.ecr.aws/sam/emulation-python3.9" + src_tag = "latest-x86_64" + self.docker_client.api.tag(f"{repo}:{src_tag}", "emulation-python3.9-alpha", tag="latest") + self.docker_client.api.tag(f"{repo}:{src_tag}", "emulation-python3.9-beta", tag="latest") + + template = ( + Path(__file__) + .resolve() + .parents[1] + .joinpath("testdata", "buildcmd", "language-extensions-dynamic-imageuri", "template.yaml") + ) + stack_name = self._method_to_stack_name(self.id()) + self.stacks.append({"name": stack_name}) + + deploy_command_list = self.get_deploy_command_list( + template_file=template, + stack_name=stack_name, + s3_prefix=self.s3_prefix, + capabilities="CAPABILITY_IAM", + image_repository=self.ecr_repo_name, + ) + deploy_process_execute = self.run_command(deploy_command_list) + self.assertEqual(deploy_process_execute.process.returncode, 0) diff --git a/tests/integration/package/test_package_command_language_extensions.py b/tests/integration/package/test_package_command_language_extensions.py new file mode 100644 index 00000000000..c9816166770 --- /dev/null +++ b/tests/integration/package/test_package_command_language_extensions.py @@ -0,0 +1,1590 @@ +""" +Integration tests for sam package with CloudFormation Language Extensions. + +These tests verify that sam package correctly processes templates using +AWS::LanguageExtensions transform, including Fn::ForEach with static and dynamic CodeUri. + +Requirements tested: +- 4.2: sam package uploads each artifact to S3 with unique content-based hash key +- 4.3: sam package generates Mappings section for dynamic artifact properties +- 4.4: sam package replaces dynamic artifact property with Fn::FindInMap +- 4.5: sam package preserves Fn::ForEach structure (not expand to individual resources) +- 4.6: Generated Mappings section name follows pattern SAM{PropertyName}{LoopName} +- 4.8: sam package emits warning for parameter-based collection with dynamic CodeUri +- 13.1: sam package uploads shared artifact once for Fn::ForEach with static CodeUri +- 13.2: sam package preserves Fn::ForEach structure in packaged template +- 13.3: S3 URI is the same for all functions generated by Fn::ForEach with static CodeUri +- 16.7: Integration tests verify sam package preserves Fn::ForEach structure +""" + +import json +import os +import re +import tempfile +from pathlib import Path +from subprocess import Popen, PIPE, TimeoutExpired +from unittest import skipIf + +from samcli.yamlhelper import yaml_parse + +from .package_integ_base import PackageIntegBase +from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY + +# Package tests require credentials and CI/CD will only add credentials to the env if the PR is from the same repo. +# This is to restrict package tests to run outside of CI/CD, when the branch is not master and tests are not run by Canary. +SKIP_PACKAGE_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY +TIMEOUT = 300 + + +@skipIf(SKIP_PACKAGE_TESTS, "Skip package tests in CI/CD only") +class TestPackageLanguageExtensions(PackageIntegBase): + """ + Integration tests for sam package with CloudFormation Language Extensions. + + Tests that sam package correctly processes templates with: + - Fn::ForEach generating multiple Lambda functions with static CodeUri + - Fn::ForEach generating multiple Lambda functions with dynamic CodeUri + - Preservation of Fn::ForEach structure in packaged template + - Same S3 URI for all functions with static CodeUri + - Generated Mappings section for dynamic CodeUri + - Fn::FindInMap replacement for dynamic artifact properties + - Unique content-based S3 keys for each artifact + + Validates Requirements: 4.2, 4.3, 4.4, 4.5, 4.6, 4.8, 13.1, 13.2, 13.3, 16.7 + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Override test_data_path to point to the language extensions test data + cls.test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "package") + + def test_package_preserves_foreach_structure_with_static_codeuri(self): + """ + Test that sam package preserves Fn::ForEach structure with static CodeUri. + + This test verifies: + 1. Package succeeds with language extensions template + 2. Packaged template preserves the Fn::ForEach structure + 3. S3 URI is the same for all functions generated by Fn::ForEach + 4. The shared artifact is uploaded once + + Validates Requirements: 13.1, 13.2, 13.3, 16.7 + """ + template_path = self.test_data_path.joinpath("language-extensions-foreach", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Verify success message + self.assertIn( + f"Successfully packaged artifacts and wrote output template to file {output_template.name}", + process_stdout, + ) + + # Read the packaged template using SAM CLI's YAML parser + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 13.2, 16.7: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "FunctionName", "Loop variable should be 'FunctionName'") + + # Verify the collection + collection = foreach_block[1] + self.assertIsInstance(collection, list, "Collection should be a list") + self.assertEqual(collection, ["Alpha", "Beta"], "Collection should contain ['Alpha', 'Beta']") + + # Verify the body template contains the function definition + body_template = foreach_block[2] + self.assertIsInstance(body_template, dict, "Body template should be a dict") + self.assertIn("${FunctionName}Function", body_template, "Body should contain function template") + + # Verify the function template structure + function_template = body_template["${FunctionName}Function"] + self.assertEqual( + function_template.get("Type"), + "AWS::Serverless::Function", + "Function template should have correct Type", + ) + + # Requirement 13.3: Verify CodeUri is updated to S3 URI + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + self.assertIsNotNone(code_uri, "CodeUri should be present in function template") + self.assertIsInstance(code_uri, str, "CodeUri should be a string (S3 URI)") + self.assertTrue( + code_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"CodeUri should be an S3 URI starting with s3://{self.s3_bucket.name}/, got: {code_uri}", + ) + + # Requirement 13.1: Verify the artifact was uploaded (check stderr for upload message) + # Since all functions share the same static CodeUri, there should be only one upload + uploads = re.findall(r"Uploading to.+", process_stderr) + self.assertGreaterEqual( + len(uploads), + 1, + "At least one artifact should be uploaded", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_generates_mappings_for_dynamic_codeuri(self): + """ + Test that sam package generates Mappings section for dynamic CodeUri. + + This test verifies: + 1. Package succeeds with dynamic CodeUri template + 2. Packaged template contains generated Mappings section (e.g., SAMCodeUriFunctions) + 3. CodeUri is replaced with Fn::FindInMap referencing the Mappings + 4. Fn::ForEach structure is preserved + 5. Each artifact has a unique content-based S3 key + + Validates Requirements: 4.2, 4.3, 4.4, 4.5, 4.6 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-codeuri", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Read the packaged template using SAM CLI's YAML parser + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 4.3, 4.6: Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMCodeUriFunctions" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each collection value + mapping_entries = mappings[mapping_name] + self.assertIn("Alpha", mapping_entries, "Mappings should contain entry for 'Alpha'") + self.assertIn("Beta", mapping_entries, "Mappings should contain entry for 'Beta'") + + # Requirement 4.2: Verify each artifact has unique S3 key (content-based hash) + alpha_uri = mapping_entries["Alpha"].get("CodeUri") + beta_uri = mapping_entries["Beta"].get("CodeUri") + + self.assertIsNotNone(alpha_uri, "Alpha CodeUri should be present in Mappings") + self.assertIsNotNone(beta_uri, "Beta CodeUri should be present in Mappings") + self.assertTrue( + alpha_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Alpha CodeUri should be an S3 URI, got: {alpha_uri}", + ) + self.assertTrue( + beta_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Beta CodeUri should be an S3 URI, got: {beta_uri}", + ) + + # Verify unique S3 keys (different content should have different hashes) + # Note: Alpha and Beta have different content, so they should have different S3 keys + self.assertNotEqual( + alpha_uri, + beta_uri, + "Alpha and Beta should have different S3 URIs (unique content-based hashes)", + ) + + # Requirement 4.5: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable and collection are preserved + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "FunctionName", "Loop variable should be 'FunctionName'") + + collection = foreach_block[1] + self.assertEqual(collection, ["Alpha", "Beta"], "Collection should be preserved") + + # Requirement 4.4: Verify CodeUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + function_template = body_template.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + self.assertIsInstance( + code_uri, + dict, + "CodeUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + code_uri, + "CodeUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = code_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "FunctionName"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "CodeUri", + "Fn::FindInMap should look up 'CodeUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_dynamic_codeuri_unique_s3_keys(self): + """ + Test that each artifact in dynamic CodeUri has a unique content-based S3 key. + + This test verifies: + 1. Package succeeds with dynamic CodeUri template + 2. Each collection value maps to a unique S3 URI + 3. S3 URIs contain content-based hashes + + Validates Requirements: 4.2 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-codeuri", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stderr: {stderr.decode('utf-8')}", + ) + + # Read the packaged template + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Get the Mappings section + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMCodeUriFunctions" + mapping_entries = mappings.get(mapping_name, {}) + + # Collect all S3 URIs + s3_uris = [] + for collection_value in ["Alpha", "Beta"]: + entry = mapping_entries.get(collection_value, {}) + code_uri = entry.get("CodeUri") + self.assertIsNotNone( + code_uri, + f"CodeUri should be present for {collection_value}", + ) + s3_uris.append(code_uri) + + # Verify all S3 URIs are unique (content-based hashing) + self.assertEqual( + len(s3_uris), + len(set(s3_uris)), + "All S3 URIs should be unique (content-based hashing)", + ) + + # Verify S3 URIs contain hash-like patterns (content-based) + for uri in s3_uris: + # S3 URI format: s3://bucket/prefix/hash.zip or s3://bucket/prefix/hash + self.assertTrue( + uri.startswith("s3://"), + f"S3 URI should start with s3://, got: {uri}", + ) + # Extract the key part (after bucket name) + key_part = uri.split("/")[-1] + # Content-based hash should be a hex string (at least 8 chars) + self.assertGreaterEqual( + len(key_part.replace(".zip", "")), + 8, + f"S3 key should contain content-based hash, got: {key_part}", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_dynamic_codeuri_with_json_output(self): + """ + Test that sam package with --use-json preserves Mappings and Fn::FindInMap for dynamic CodeUri. + + This test verifies: + 1. Package succeeds with --use-json flag + 2. JSON output contains generated Mappings section + 3. JSON output preserves Fn::ForEach structure with Fn::FindInMap + + Validates Requirements: 4.3, 4.4, 4.5 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-codeuri", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + use_json=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stderr: {stderr.decode('utf-8')}", + ) + + # Read the packaged template as JSON + with open(output_template.name, "r") as f: + packaged_template = json.load(f) + + # Verify Mappings section is present in JSON output + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMCodeUriFunctions" + + self.assertIn( + mapping_name, + mappings, + f"JSON packaged template should contain {mapping_name} Mappings", + ) + + # Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + f"JSON packaged template should preserve {foreach_key} structure", + ) + + # Verify CodeUri is Fn::FindInMap in JSON output + foreach_block = resources[foreach_key] + body_template = foreach_block[2] + function_template = body_template.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + self.assertIsInstance( + code_uri, + dict, + "CodeUri should be a dict in JSON output", + ) + self.assertIn( + "Fn::FindInMap", + code_uri, + "CodeUri should contain Fn::FindInMap in JSON output", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_foreach_same_s3_uri_for_all_functions(self): + """ + Test that sam package uses the same S3 URI for all functions with static CodeUri. + + This test verifies: + 1. Package succeeds with language extensions template + 2. The S3 URI in the packaged template is the same for all functions + 3. The Fn::ForEach structure is preserved with the shared S3 URI + + Validates Requirements: 13.1, 13.3 + """ + template_path = self.test_data_path.joinpath("language-extensions-foreach", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stderr: {stderr.decode('utf-8')}", + ) + + # Read the packaged template using SAM CLI's YAML parser + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Get the Fn::ForEach block + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + foreach_block = resources.get(foreach_key) + + self.assertIsNotNone(foreach_block, f"{foreach_key} should exist in packaged template") + + # Get the function template from the body + body_template = foreach_block[2] + function_template = body_template.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + # Requirement 13.3: The S3 URI should be the same for all functions + # Since the Fn::ForEach structure is preserved with a single CodeUri value, + # all expanded functions will use the same S3 URI + self.assertIsNotNone(code_uri, "CodeUri should be present") + self.assertTrue( + code_uri.startswith("s3://"), + f"CodeUri should be an S3 URI, got: {code_uri}", + ) + + # Verify the S3 URI contains the bucket name and a hash + self.assertIn( + self.s3_bucket.name, + code_uri, + f"S3 URI should contain bucket name {self.s3_bucket.name}", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_foreach_with_json_output(self): + """ + Test that sam package with --use-json preserves Fn::ForEach structure. + + This test verifies: + 1. Package succeeds with --use-json flag + 2. JSON output preserves Fn::ForEach structure + 3. S3 URI is correctly set in JSON output + + Validates Requirements: 13.2, 13.3 + """ + template_path = self.test_data_path.joinpath("language-extensions-foreach", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + use_json=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stderr: {stderr.decode('utf-8')}", + ) + + # Read the packaged template as JSON + with open(output_template.name, "r") as f: + packaged_template = json.load(f) + + # Verify Fn::ForEach structure is preserved in JSON output + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + f"JSON packaged template should preserve {foreach_key} structure", + ) + + # Verify the structure + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual(len(foreach_block), 3, "Fn::ForEach block should have 3 elements") + + # Verify CodeUri is S3 URI + body_template = foreach_block[2] + function_template = body_template.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + self.assertTrue( + code_uri.startswith("s3://"), + f"CodeUri should be an S3 URI in JSON output, got: {code_uri}", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_emits_warning_for_param_based_collection_with_dynamic_codeuri(self): + """ + Test that sam package emits warning for parameter-based collection with dynamic CodeUri. + + This test verifies: + 1. Package succeeds with parameter-based collection and dynamic CodeUri + 2. A warning is emitted about collection values being fixed at package time + 3. Packaged template contains generated Mappings section + 4. Fn::ForEach structure is preserved + + Validates Requirements: 4.8 + """ + template_path = self.test_data_path.joinpath("language-extensions-param-dynamic-codeuri", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Requirement 4.8: Verify warning is emitted about collection values being fixed at package time + # The warning should mention: + # - The Fn::ForEach loop name (Services) + # - Dynamic CodeUri + # - Parameter-based collection + # - Need to re-package if parameter changes + combined_output = process_stdout + process_stderr + + # Check for warning about parameter-based collection with dynamic CodeUri + warning_patterns = [ + r"[Ww]arning", # Should contain "Warning" or "warning" + r"Services", # Should mention the ForEach loop name + r"(parameter|re-?package|fixed|package time)", # Should mention parameter or re-package + ] + + warning_found = False + for pattern in warning_patterns: + if re.search(pattern, combined_output, re.IGNORECASE): + warning_found = True + break + + self.assertTrue( + warning_found, + f"Expected warning about parameter-based collection with dynamic CodeUri. " + f"Output was:\nstdout: {process_stdout}\nstderr: {process_stderr}", + ) + + # Verify success message is still present + self.assertIn( + f"Successfully packaged artifacts and wrote output template to file {output_template.name}", + process_stdout, + ) + + # Read the packaged template using SAM CLI's YAML parser + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMCodeUriServices" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each default collection value + mapping_entries = mappings[mapping_name] + self.assertIn("Users", mapping_entries, "Mappings should contain entry for 'Users'") + self.assertIn("Orders", mapping_entries, "Mappings should contain entry for 'Orders'") + + # Verify each artifact has S3 URI + users_uri = mapping_entries["Users"].get("CodeUri") + orders_uri = mapping_entries["Orders"].get("CodeUri") + + self.assertIsNotNone(users_uri, "Users CodeUri should be present in Mappings") + self.assertIsNotNone(orders_uri, "Orders CodeUri should be present in Mappings") + self.assertTrue( + users_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Users CodeUri should be an S3 URI, got: {users_uri}", + ) + self.assertTrue( + orders_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Orders CodeUri should be an S3 URI, got: {orders_uri}", + ) + + # Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Services" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "Name", "Loop variable should be 'Name'") + + # Verify CodeUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + function_template = body_template.get("${Name}Service", {}) + properties = function_template.get("Properties", {}) + code_uri = properties.get("CodeUri") + + self.assertIsInstance( + code_uri, + dict, + "CodeUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + code_uri, + "CodeUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = code_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "Name"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "CodeUri", + "Fn::FindInMap should look up 'CodeUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_fails_for_invalid_mapping_key_characters(self): + """ + Test that sam package fails with clear error for invalid Mapping key characters. + + This test verifies: + 1. Package fails when collection values contain invalid characters for CloudFormation Mapping keys + 2. Error message clearly indicates which values are invalid + 3. Error message explains valid characters (alphanumeric, hyphens, underscores) + + CloudFormation Mapping keys can only contain alphanumeric characters (a-z, A-Z, 0-9), + hyphens (-), and underscores (_). Characters like dots (.), slashes (/), and spaces + are not allowed. + + Validates Requirements: 4.10 + """ + template_path = self.test_data_path.joinpath("language-extensions-invalid-mapping-keys", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + combined_output = process_stdout + process_stderr + + # Verify package fails (non-zero exit code) + self.assertNotEqual( + process.returncode, + 0, + f"Package should fail for invalid Mapping key characters. " + f"stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Verify error message mentions invalid Mapping key characters + # The error should mention: + # - "Invalid" or "invalid" + # - "Mapping" or "mapping" + # - The invalid value "order.service" + # - Valid characters (alphanumeric, hyphens, underscores) + self.assertTrue( + "invalid" in combined_output.lower() or "Invalid" in combined_output, + f"Error message should mention 'invalid'. Output: {combined_output}", + ) + + self.assertTrue( + "order.service" in combined_output, + f"Error message should mention the invalid value 'order.service'. Output: {combined_output}", + ) + + # Verify error message explains valid characters + # Should mention alphanumeric, hyphens, or underscores + valid_chars_mentioned = any( + term in combined_output.lower() + for term in ["alphanumeric", "hyphen", "underscore", "a-z", "0-9", "_", "-"] + ) + self.assertTrue( + valid_chars_mentioned, + f"Error message should explain valid characters. Output: {combined_output}", + ) + + # Clean up + try: + os.unlink(output_template.name) + except FileNotFoundError: + pass # File may not have been created if package failed early + + +@skipIf(SKIP_PACKAGE_TESTS, "Skip package tests in CI/CD only") +class TestPackageLanguageExtensionsArtifactFormats(PackageIntegBase): + """ + Integration tests for sam package with all artifact property formats in Fn::ForEach. + + Tests that sam package correctly generates Mappings for: + - AWS::Serverless::LayerVersion with dynamic ContentUri + - AWS::Serverless::Api with dynamic DefinitionUri + - AWS::Serverless::StateMachine with dynamic DefinitionUri + + Validates Requirements: 20.1, 20.2, 20.3, 20.4, 20.5, 20.6, 20.7 + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "package") + + def test_package_generates_mappings_for_dynamic_contenturi_layer(self): + """ + Test that sam package generates Mappings for LayerVersion with dynamic ContentUri. + + This test verifies: + 1. Package succeeds with dynamic ContentUri template + 2. Packaged template contains generated Mappings section (e.g., SAMContentUriLayers) + 3. ContentUri is replaced with Fn::FindInMap referencing the Mappings + 4. Fn::ForEach structure is preserved + 5. Each layer artifact has a unique content-based S3 key + + Validates Requirements: 20.1, 20.5, 20.6, 20.7 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-contenturi", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Read the packaged template + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 20.5: Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMContentUriLayers" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each collection value + mapping_entries = mappings[mapping_name] + self.assertIn("Common", mapping_entries, "Mappings should contain entry for 'Common'") + self.assertIn("Utils", mapping_entries, "Mappings should contain entry for 'Utils'") + + # Verify each layer has unique S3 key (content-based hash) + common_uri = mapping_entries["Common"].get("ContentUri") + utils_uri = mapping_entries["Utils"].get("ContentUri") + + self.assertIsNotNone(common_uri, "Common ContentUri should be present in Mappings") + self.assertIsNotNone(utils_uri, "Utils ContentUri should be present in Mappings") + self.assertTrue( + common_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Common ContentUri should be an S3 URI, got: {common_uri}", + ) + self.assertTrue( + utils_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Utils ContentUri should be an S3 URI, got: {utils_uri}", + ) + + # Verify unique S3 keys (different content should have different hashes) + self.assertNotEqual( + common_uri, + utils_uri, + "Common and Utils should have different S3 URIs (unique content-based hashes)", + ) + + # Requirement 20.7: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Layers" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable and collection are preserved + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "LayerName", "Loop variable should be 'LayerName'") + + collection = foreach_block[1] + self.assertEqual(collection, ["Common", "Utils"], "Collection should be preserved") + + # Requirement 20.6: Verify ContentUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + layer_template = body_template.get("${LayerName}Layer", {}) + properties = layer_template.get("Properties", {}) + content_uri = properties.get("ContentUri") + + self.assertIsInstance( + content_uri, + dict, + "ContentUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + content_uri, + "ContentUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = content_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "LayerName"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "ContentUri", + "Fn::FindInMap should look up 'ContentUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_generates_mappings_for_dynamic_definitionuri_api(self): + """ + Test that sam package generates Mappings for Api with dynamic DefinitionUri. + + This test verifies: + 1. Package succeeds with dynamic DefinitionUri template for APIs + 2. Packaged template contains generated Mappings section (e.g., SAMDefinitionUriAPIs) + 3. DefinitionUri is replaced with Fn::FindInMap referencing the Mappings + 4. Fn::ForEach structure is preserved + 5. Each API spec has a unique content-based S3 key + + Validates Requirements: 20.2, 20.5, 20.6, 20.7 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-definitionuri-api", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Read the packaged template + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 20.5: Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMDefinitionUriAPIs" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each collection value + mapping_entries = mappings[mapping_name] + self.assertIn("Users", mapping_entries, "Mappings should contain entry for 'Users'") + self.assertIn("Orders", mapping_entries, "Mappings should contain entry for 'Orders'") + + # Verify each API spec has unique S3 key (content-based hash) + users_uri = mapping_entries["Users"].get("DefinitionUri") + orders_uri = mapping_entries["Orders"].get("DefinitionUri") + + self.assertIsNotNone(users_uri, "Users DefinitionUri should be present in Mappings") + self.assertIsNotNone(orders_uri, "Orders DefinitionUri should be present in Mappings") + self.assertTrue( + users_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Users DefinitionUri should be an S3 URI, got: {users_uri}", + ) + self.assertTrue( + orders_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Orders DefinitionUri should be an S3 URI, got: {orders_uri}", + ) + + # Verify unique S3 keys (different content should have different hashes) + self.assertNotEqual( + users_uri, + orders_uri, + "Users and Orders should have different S3 URIs (unique content-based hashes)", + ) + + # Requirement 20.7: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::APIs" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable and collection are preserved + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "ApiName", "Loop variable should be 'ApiName'") + + collection = foreach_block[1] + self.assertEqual(collection, ["Users", "Orders"], "Collection should be preserved") + + # Requirement 20.6: Verify DefinitionUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + api_template = body_template.get("${ApiName}Api", {}) + properties = api_template.get("Properties", {}) + definition_uri = properties.get("DefinitionUri") + + self.assertIsInstance( + definition_uri, + dict, + "DefinitionUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + definition_uri, + "DefinitionUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = definition_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "ApiName"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "DefinitionUri", + "Fn::FindInMap should look up 'DefinitionUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_generates_mappings_for_dynamic_definitionuri_statemachine(self): + """ + Test that sam package generates Mappings for StateMachine with dynamic DefinitionUri. + + This test verifies: + 1. Package succeeds with dynamic DefinitionUri template for State Machines + 2. Packaged template contains generated Mappings section (e.g., SAMDefinitionUriWorkflows) + 3. DefinitionUri is replaced with Fn::FindInMap referencing the Mappings + 4. Fn::ForEach structure is preserved + 5. Each state machine definition has a unique content-based S3 key + + Validates Requirements: 20.3, 20.5, 20.6, 20.7 + """ + template_path = self.test_data_path.joinpath( + "language-extensions-dynamic-definitionuri-statemachine", "template.yaml" + ) + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + s3_bucket=self.s3_bucket.name, + template=template_path, + s3_prefix=self.s3_prefix, + output_template_file=output_template.name, + force_upload=True, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Read the packaged template + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 20.5: Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMDefinitionUriWorkflows" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each collection value + mapping_entries = mappings[mapping_name] + self.assertIn("Process", mapping_entries, "Mappings should contain entry for 'Process'") + self.assertIn("Notify", mapping_entries, "Mappings should contain entry for 'Notify'") + + # Verify each state machine definition has unique S3 key (content-based hash) + process_uri = mapping_entries["Process"].get("DefinitionUri") + notify_uri = mapping_entries["Notify"].get("DefinitionUri") + + self.assertIsNotNone(process_uri, "Process DefinitionUri should be present in Mappings") + self.assertIsNotNone(notify_uri, "Notify DefinitionUri should be present in Mappings") + self.assertTrue( + process_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Process DefinitionUri should be an S3 URI, got: {process_uri}", + ) + self.assertTrue( + notify_uri.startswith(f"s3://{self.s3_bucket.name}/"), + f"Notify DefinitionUri should be an S3 URI, got: {notify_uri}", + ) + + # Verify unique S3 keys (different content should have different hashes) + self.assertNotEqual( + process_uri, + notify_uri, + "Process and Notify should have different S3 URIs (unique content-based hashes)", + ) + + # Requirement 20.7: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Workflows" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable and collection are preserved + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "WorkflowName", "Loop variable should be 'WorkflowName'") + + collection = foreach_block[1] + self.assertEqual(collection, ["Process", "Notify"], "Collection should be preserved") + + # Requirement 20.6: Verify DefinitionUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + workflow_template = body_template.get("${WorkflowName}Workflow", {}) + properties = workflow_template.get("Properties", {}) + definition_uri = properties.get("DefinitionUri") + + self.assertIsInstance( + definition_uri, + dict, + "DefinitionUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + definition_uri, + "DefinitionUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = definition_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "WorkflowName"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "DefinitionUri", + "Fn::FindInMap should look up 'DefinitionUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + +@skipIf(SKIP_PACKAGE_TESTS, "Skip package tests in CI/CD only") +class TestPackageLanguageExtensionsImageUri(PackageIntegBase): + """ + Integration tests for sam package with Fn::ForEach and dynamic ImageUri. + + Tests that sam package correctly generates Mappings for: + - AWS::Serverless::Function with dynamic ImageUri (container images) + + Validates Requirements: 19.2, 20.5, 20.6, 20.7 + """ + + @classmethod + def setUpClass(cls): + from samcli.local.docker.utils import get_validated_container_client + + cls.docker_client = get_validated_container_client() + cls.local_images = [ + ("public.ecr.aws/sam/emulation-python3.9", "latest"), + ] + # Setup local images by pulling and tagging them for the test + # Docker repository names must be lowercase + for repo, tag in cls.local_images: + cls.docker_client.api.pull(repository=repo, tag=tag) + # Tag for alpha and beta functions + cls.docker_client.api.tag(f"{repo}:{tag}", "emulation-python3.9-alpha", tag="latest") + cls.docker_client.api.tag(f"{repo}:{tag}", "emulation-python3.9-beta", tag="latest") + + super().setUpClass() + cls.test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "package") + + def test_package_generates_mappings_for_dynamic_imageuri(self): + """ + Test that sam package generates Mappings for Function with dynamic ImageUri. + + This test verifies: + 1. Package succeeds with dynamic ImageUri template + 2. Packaged template contains generated Mappings section (e.g., SAMImageUriFunctions) + 3. ImageUri is replaced with Fn::FindInMap referencing the Mappings + 4. Fn::ForEach structure is preserved + 5. Each container image has a unique ECR URI + + Validates Requirements: 19.2, 20.5, 20.6, 20.7 + """ + template_path = self.test_data_path.joinpath("language-extensions-dynamic-imageuri", "template.yaml") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as output_template: + command_list = PackageIntegBase.get_command_list( + image_repository=self.ecr_repo_name, + template=template_path, + output_template_file=output_template.name, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + process_stdout = stdout.strip().decode("utf-8") + process_stderr = stderr.strip().decode("utf-8") + + # Verify package succeeds + self.assertEqual( + process.returncode, + 0, + f"Package failed with stdout: {process_stdout}, stderr: {process_stderr}", + ) + + # Read the packaged template + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Requirement 20.5: Verify Mappings section is generated with correct naming + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMImageUriFunctions" # SAM{PropertyName}{LoopName} + + self.assertIn( + mapping_name, + mappings, + f"Packaged template should contain generated Mappings section '{mapping_name}'", + ) + + # Verify Mappings contains entries for each collection value + mapping_entries = mappings[mapping_name] + self.assertIn("alpha", mapping_entries, "Mappings should contain entry for 'alpha'") + self.assertIn("beta", mapping_entries, "Mappings should contain entry for 'beta'") + + # Verify each function has unique ECR URI + alpha_uri = mapping_entries["alpha"].get("ImageUri") + beta_uri = mapping_entries["beta"].get("ImageUri") + + self.assertIsNotNone(alpha_uri, "alpha ImageUri should be present in Mappings") + self.assertIsNotNone(beta_uri, "beta ImageUri should be present in Mappings") + + # ECR URIs should contain the ECR repo name + self.assertIn( + self.ecr_repo_name.split("/")[-1], # Get repo name without account prefix + alpha_uri, + f"Alpha ImageUri should contain ECR repo, got: {alpha_uri}", + ) + self.assertIn( + self.ecr_repo_name.split("/")[-1], + beta_uri, + f"Beta ImageUri should contain ECR repo, got: {beta_uri}", + ) + + # Verify unique ECR URIs (different images should have different digests) + self.assertNotEqual( + alpha_uri, + beta_uri, + "Alpha and Beta should have different ECR URIs (unique image digests)", + ) + + # Requirement 20.7: Verify Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + foreach_key = "Fn::ForEach::Functions" + + self.assertIn( + foreach_key, + resources, + f"Packaged template should preserve {foreach_key} structure", + ) + + # Verify the Fn::ForEach structure is correct + foreach_block = resources[foreach_key] + self.assertIsInstance(foreach_block, list, "Fn::ForEach block should be a list") + self.assertEqual( + len(foreach_block), + 3, + "Fn::ForEach block should have 3 elements: variable, collection, body", + ) + + # Verify the loop variable and collection are preserved + loop_variable = foreach_block[0] + self.assertEqual(loop_variable, "FunctionName", "Loop variable should be 'FunctionName'") + + collection = foreach_block[1] + self.assertEqual(collection, ["alpha", "beta"], "Collection should be preserved") + + # Requirement 20.6: Verify ImageUri is replaced with Fn::FindInMap + body_template = foreach_block[2] + function_template = body_template.get("${FunctionName}Function", {}) + properties = function_template.get("Properties", {}) + image_uri = properties.get("ImageUri") + + self.assertIsInstance( + image_uri, + dict, + "ImageUri should be a dict (Fn::FindInMap reference)", + ) + self.assertIn( + "Fn::FindInMap", + image_uri, + "ImageUri should contain Fn::FindInMap", + ) + + # Verify Fn::FindInMap structure + find_in_map = image_uri["Fn::FindInMap"] + self.assertIsInstance(find_in_map, list, "Fn::FindInMap should be a list") + self.assertEqual(len(find_in_map), 3, "Fn::FindInMap should have 3 elements") + self.assertEqual( + find_in_map[0], + mapping_name, + f"Fn::FindInMap should reference {mapping_name}", + ) + self.assertEqual( + find_in_map[1], + {"Ref": "FunctionName"}, + "Fn::FindInMap should use loop variable as key", + ) + self.assertEqual( + find_in_map[2], + "ImageUri", + "Fn::FindInMap should look up 'ImageUri' property", + ) + + # Clean up + os.unlink(output_template.name) + + def test_package_nested_foreach_dynamic_codeuri_generates_mappings(self): + """ + Test that sam package with nested Fn::ForEach and dynamic CodeUri generates Mappings. + + Validates Requirements: 25.5, 25.9 + """ + template_path = self.test_data_path.joinpath( + "language-extensions-nested-foreach-dynamic-codeuri", "template.yaml" + ) + + output_template = tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") + try: + command_list = self.get_command_list( + s3_bucket=self.s3_bucket.name, + template_file=str(template_path), + output_template_file=output_template.name, + ) + + process = Popen(command_list, stdout=PIPE, stderr=PIPE) + try: + stdout, stderr = process.communicate(timeout=TIMEOUT) + except TimeoutExpired: + process.kill() + raise + + self.assertEqual( + process.returncode, + 0, + f"Package failed: {stderr.decode('utf-8')}", + ) + + with open(output_template.name, "r") as f: + packaged_template = yaml_parse(f.read()) + + # Verify nested Fn::ForEach structure is preserved + resources = packaged_template.get("Resources", {}) + outer_key = "Fn::ForEach::Environments" + self.assertIn(outer_key, resources) + + outer_block = resources[outer_key] + inner_block = outer_block[2]["Fn::ForEach::Services"] + self.assertEqual(inner_block[0], "Service") + self.assertEqual(inner_block[1], ["Users", "Orders"]) + + # Verify Mappings section was generated + mappings = packaged_template.get("Mappings", {}) + mapping_name = "SAMCodeUriServices" + self.assertIn(mapping_name, mappings) + self.assertIn("Users", mappings[mapping_name]) + self.assertIn("Orders", mappings[mapping_name]) + + # Verify S3 URIs in Mappings + users_uri = mappings[mapping_name]["Users"]["CodeUri"] + orders_uri = mappings[mapping_name]["Orders"]["CodeUri"] + self.assertTrue(users_uri.startswith("s3://"), f"Users URI should be S3: {users_uri}") + self.assertTrue(orders_uri.startswith("s3://"), f"Orders URI should be S3: {orders_uri}") + + # Verify CodeUri replaced with Fn::FindInMap + inner_body = inner_block[2] + props = inner_body["${Env}${Service}Function"]["Properties"] + codeuri = props["CodeUri"] + self.assertIsInstance(codeuri, dict) + self.assertIn("Fn::FindInMap", codeuri) + self.assertEqual(codeuri["Fn::FindInMap"][0], mapping_name) + finally: + os.unlink(output_template.name) diff --git a/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Alpha/main.py b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Alpha/main.py new file mode 100644 index 00000000000..87761b31648 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Alpha/main.py @@ -0,0 +1,12 @@ +"""Alpha function handler for package tests.""" + + +def handler(event, context): + """Lambda handler for Alpha function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Alpha", + "function": "AlphaFunction" + } + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Beta/main.py b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Beta/main.py new file mode 100644 index 00000000000..79df3743606 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/Beta/main.py @@ -0,0 +1,12 @@ +"""Beta function handler for package tests.""" + + +def handler(event, context): + """Lambda handler for Beta function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Beta", + "function": "BetaFunction" + } + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-codeuri/template.yaml b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/template.yaml new file mode 100644 index 00000000000..fa1269f59db --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-codeuri/template.yaml @@ -0,0 +1,48 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and dynamic CodeUri. + This template generates multiple Lambda functions using Fn::ForEach + with dynamic CodeUri (CodeUri: ${FunctionName}/), which should result + in a generated Mappings section and Fn::FindInMap references. + +Parameters: + Runtime: + Type: String + Default: python3.13 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: !Ref Runtime + +Resources: + # Test Fn::ForEach generating multiple Lambda functions with dynamic CodeUri + # This generates AlphaFunction and BetaFunction, each with their own CodeUri + # AlphaFunction -> CodeUri: Alpha/ + # BetaFunction -> CodeUri: Beta/ + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ${FunctionName}/ + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + +Outputs: + AlphaFunctionArn: + Description: ARN of the Alpha function + Value: !GetAtt AlphaFunction.Arn + BetaFunctionArn: + Description: ARN of the Beta function + Value: !GetAtt BetaFunction.Arn diff --git a/tests/integration/testdata/package/language-extensions-dynamic-contenturi/function/index.py b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/function/index.py new file mode 100644 index 00000000000..6aef3ca4c97 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/function/index.py @@ -0,0 +1,9 @@ +# Test function that uses the layers +# This file is part of the language-extensions-dynamic-contenturi test + +def handler(event, context): + """Lambda handler that uses the Common and Utils layers.""" + return { + "statusCode": 200, + "body": "Hello from test function" + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Common/python/common_utils.py b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Common/python/common_utils.py new file mode 100644 index 00000000000..841f7ae9a50 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Common/python/common_utils.py @@ -0,0 +1,16 @@ +# Common utilities layer +# This file is part of the Common layer for language-extensions-dynamic-contenturi test + +def get_common_config(): + """Return common configuration.""" + return { + "layer": "Common", + "version": "1.0.0" + } + +def format_response(data): + """Format a standard response.""" + return { + "statusCode": 200, + "body": data + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Utils/python/utils.py b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Utils/python/utils.py new file mode 100644 index 00000000000..ebf303c16dc --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/layers/Utils/python/utils.py @@ -0,0 +1,19 @@ +# Utils layer +# This file is part of the Utils layer for language-extensions-dynamic-contenturi test + +import json + +def to_json(data): + """Convert data to JSON string.""" + return json.dumps(data) + +def from_json(json_str): + """Parse JSON string to data.""" + return json.loads(json_str) + +def get_utils_info(): + """Return utils layer info.""" + return { + "layer": "Utils", + "version": "1.0.0" + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-contenturi/template.yaml b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/template.yaml new file mode 100644 index 00000000000..9573d871997 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-contenturi/template.yaml @@ -0,0 +1,53 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and dynamic ContentUri. + This template generates multiple Lambda layers using Fn::ForEach + with dynamic ContentUri (ContentUri: ./layers/${LayerName}/), which should result + in a generated Mappings section and Fn::FindInMap references. + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: python3.13 + +Resources: + # Test Fn::ForEach generating multiple Lambda layers with dynamic ContentUri + # This generates CommonLayer and UtilsLayer, each with their own ContentUri + # CommonLayer -> ContentUri: ./layers/Common/ + # UtilsLayer -> ContentUri: ./layers/Utils/ + Fn::ForEach::Layers: + - LayerName + - - Common + - Utils + - ${LayerName}Layer: + Type: AWS::Serverless::LayerVersion + Properties: + LayerName: !Sub ${LayerName}Layer + Description: !Sub ${LayerName} shared layer + ContentUri: ./layers/${LayerName}/ + CompatibleRuntimes: + - python3.13 + - python3.12 + + # A function that uses the layers (for completeness) + TestFunction: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + CodeUri: ./function/ + Layers: + - !Ref CommonLayer + - !Ref UtilsLayer + +Outputs: + CommonLayerArn: + Description: ARN of the Common layer + Value: !Ref CommonLayer + UtilsLayerArn: + Description: ARN of the Utils layer + Value: !Ref UtilsLayer diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Orders.yaml b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Orders.yaml new file mode 100644 index 00000000000..cfed09b71af --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Orders.yaml @@ -0,0 +1,46 @@ +openapi: "3.0.1" +info: + title: "Orders API" + description: "API for order management" + version: "1.0.0" +paths: + /orders: + get: + summary: "List all orders" + operationId: "listOrders" + responses: + "200": + description: "Successful response" + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + status: + type: string + total: + type: number + x-amazon-apigateway-integration: + type: mock + requestTemplates: + application/json: '{"statusCode": 200}' + responses: + default: + statusCode: "200" + post: + summary: "Create an order" + operationId: "createOrder" + responses: + "201": + description: "Order created" + x-amazon-apigateway-integration: + type: mock + requestTemplates: + application/json: '{"statusCode": 201}' + responses: + default: + statusCode: "201" diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Users.yaml b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Users.yaml new file mode 100644 index 00000000000..a3bb7f38b10 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/api/Users.yaml @@ -0,0 +1,44 @@ +openapi: "3.0.1" +info: + title: "Users API" + description: "API for user management" + version: "1.0.0" +paths: + /users: + get: + summary: "List all users" + operationId: "listUsers" + responses: + "200": + description: "Successful response" + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + x-amazon-apigateway-integration: + type: mock + requestTemplates: + application/json: '{"statusCode": 200}' + responses: + default: + statusCode: "200" + post: + summary: "Create a user" + operationId: "createUser" + responses: + "201": + description: "User created" + x-amazon-apigateway-integration: + type: mock + requestTemplates: + application/json: '{"statusCode": 201}' + responses: + default: + statusCode: "201" diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/functions/index.py b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/functions/index.py new file mode 100644 index 00000000000..0992265d7df --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/functions/index.py @@ -0,0 +1,14 @@ +# Backend function for APIs +# This file is part of the language-extensions-dynamic-definitionuri-api test + +import json + +def handler(event, context): + """Lambda handler for API backend.""" + return { + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": json.dumps({"message": "Hello from API backend"}) + } diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/template.yaml b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/template.yaml new file mode 100644 index 00000000000..0ac1da2c28f --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-api/template.yaml @@ -0,0 +1,51 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and dynamic DefinitionUri for APIs. + This template generates multiple API Gateway APIs using Fn::ForEach + with dynamic DefinitionUri (DefinitionUri: ./api/${ApiName}.yaml), which should result + in a generated Mappings section and Fn::FindInMap references. + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: python3.13 + +Resources: + # Test Fn::ForEach generating multiple APIs with dynamic DefinitionUri + # This generates UsersApi and OrdersApi, each with their own OpenAPI spec + # UsersApi -> DefinitionUri: ./api/Users.yaml + # OrdersApi -> DefinitionUri: ./api/Orders.yaml + Fn::ForEach::APIs: + - ApiName + - - Users + - Orders + - ${ApiName}Api: + Type: AWS::Serverless::Api + Properties: + Name: !Sub ${ApiName}Api + StageName: prod + DefinitionUri: ./api/${ApiName}.yaml + + # Backend functions for the APIs + Fn::ForEach::Functions: + - FuncName + - - Users + - Orders + - ${FuncName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + CodeUri: ./functions/ + +Outputs: + UsersApiEndpoint: + Description: API Gateway endpoint URL for Users API + Value: !Sub "https://${UsersApi}.execute-api.${AWS::Region}.amazonaws.com/prod/orders" + OrdersApiEndpoint: + Description: API Gateway endpoint URL for Orders API + Value: !Sub "https://${OrdersApi}.execute-api.${AWS::Region}.amazonaws.com/prod/users" diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Notify.asl.json b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Notify.asl.json new file mode 100644 index 00000000000..6b8df6aecfc --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Notify.asl.json @@ -0,0 +1,27 @@ +{ + "Comment": "Notify workflow state machine", + "StartAt": "PrepareNotification", + "States": { + "PrepareNotification": { + "Type": "Pass", + "Comment": "Prepare the notification", + "Result": { + "prepared": true, + "workflow": "Notify" + }, + "Next": "SendNotification" + }, + "SendNotification": { + "Type": "Pass", + "Comment": "Send the notification", + "Result": { + "sent": true + }, + "Next": "Complete" + }, + "Complete": { + "Type": "Succeed", + "Comment": "Notification sent" + } + } +} diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Process.asl.json b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Process.asl.json new file mode 100644 index 00000000000..8b580ee2e74 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/statemachines/Process.asl.json @@ -0,0 +1,27 @@ +{ + "Comment": "Process workflow state machine", + "StartAt": "ValidateInput", + "States": { + "ValidateInput": { + "Type": "Pass", + "Comment": "Validate the input data", + "Result": { + "validated": true, + "workflow": "Process" + }, + "Next": "ProcessData" + }, + "ProcessData": { + "Type": "Pass", + "Comment": "Process the data", + "Result": { + "processed": true + }, + "Next": "Complete" + }, + "Complete": { + "Type": "Succeed", + "Comment": "Processing complete" + } + } +} diff --git a/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/template.yaml b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/template.yaml new file mode 100644 index 00000000000..7583abb3f23 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-definitionuri-statemachine/template.yaml @@ -0,0 +1,57 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and dynamic DefinitionUri for State Machines. + This template generates multiple Step Functions state machines using Fn::ForEach + with dynamic DefinitionUri (DefinitionUri: ./statemachines/${WorkflowName}.asl.json), which should result + in a generated Mappings section and Fn::FindInMap references. + +Resources: + # Test Fn::ForEach generating multiple state machines with dynamic DefinitionUri + # This generates ProcessWorkflow and NotifyWorkflow, each with their own ASL definition + # ProcessWorkflow -> DefinitionUri: ./statemachines/Process.asl.json + # NotifyWorkflow -> DefinitionUri: ./statemachines/Notify.asl.json + Fn::ForEach::Workflows: + - WorkflowName + - - Process + - Notify + - ${WorkflowName}Workflow: + Type: AWS::Serverless::StateMachine + Properties: + Name: !Sub ${WorkflowName}Workflow + DefinitionUri: ./statemachines/${WorkflowName}.asl.json + Role: !GetAtt StateMachineRole.Arn + + # IAM role for state machines + StateMachineRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: states.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: StateMachinePolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: "*" + +Outputs: + ProcessWorkflowArn: + Description: ARN of the Process workflow + Value: !Ref ProcessWorkflow + NotifyWorkflowArn: + Description: ARN of the Notify workflow + Value: !Ref NotifyWorkflow diff --git a/tests/integration/testdata/package/language-extensions-dynamic-imageuri/template.yaml b/tests/integration/testdata/package/language-extensions-dynamic-imageuri/template.yaml new file mode 100644 index 00000000000..75598f9b051 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-dynamic-imageuri/template.yaml @@ -0,0 +1,36 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and dynamic ImageUri. + This template generates multiple container image Lambda functions using Fn::ForEach + with dynamic ImageUri (ImageUri: emulation-python3.9-${FunctionName}:latest), which should result + in a generated Mappings section and Fn::FindInMap references. + +Resources: + # Test Fn::ForEach generating multiple container image functions with dynamic ImageUri + # This generates alphaFunction and betaFunction, each with their own image + # alphaFunction -> ImageUri: emulation-python3.9-alpha:latest + # betaFunction -> ImageUri: emulation-python3.9-beta:latest + # NOTE: Collection values must be lowercase because Docker repository names are case-sensitive + # and must be lowercase. + Fn::ForEach::Functions: + - FunctionName + - - alpha + - beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${FunctionName}Function + PackageType: Image + ImageUri: emulation-python3.9-${FunctionName}:latest + +Outputs: + alphaFunctionArn: + Description: ARN of the alpha function + Value: !GetAtt alphaFunction.Arn + betaFunctionArn: + Description: ARN of the beta function + Value: !GetAtt betaFunction.Arn diff --git a/tests/integration/testdata/package/language-extensions-foreach/src/__init__.py b/tests/integration/testdata/package/language-extensions-foreach/src/__init__.py new file mode 100644 index 00000000000..88406b43770 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-foreach/src/__init__.py @@ -0,0 +1 @@ +# Lambda function source for language extensions package test diff --git a/tests/integration/testdata/package/language-extensions-foreach/src/main.py b/tests/integration/testdata/package/language-extensions-foreach/src/main.py new file mode 100644 index 00000000000..a8cd008301f --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-foreach/src/main.py @@ -0,0 +1,16 @@ +""" +Lambda function handler for language extensions package test. +""" + +import os + + +def handler(event, context): + """ + Simple Lambda handler that returns the function name from environment variable. + """ + function_name = os.environ.get("FUNCTION_NAME", "Unknown") + return { + "statusCode": 200, + "body": f"Hello from {function_name}Function!" + } diff --git a/tests/integration/testdata/package/language-extensions-foreach/template.yaml b/tests/integration/testdata/package/language-extensions-foreach/template.yaml new file mode 100644 index 00000000000..0c81a289b0b --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-foreach/template.yaml @@ -0,0 +1,46 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and static CodeUri. + This template generates multiple Lambda functions using Fn::ForEach + with a shared static CodeUri, which should result in the same S3 URI + for all functions in the packaged template. + +Parameters: + Runtime: + Type: String + Default: python3.12 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: !Ref Runtime + +Resources: + # Test Fn::ForEach generating multiple Lambda functions with static CodeUri + # This generates AlphaFunction and BetaFunction, both using the same CodeUri + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: src + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + +Outputs: + AlphaFunctionArn: + Description: ARN of the Alpha function + Value: !GetAtt AlphaFunction.Arn + BetaFunctionArn: + Description: ARN of the Beta function + Value: !GetAtt BetaFunction.Arn diff --git a/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/order.service/main.py b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/order.service/main.py new file mode 100644 index 00000000000..77f16a28603 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/order.service/main.py @@ -0,0 +1,12 @@ +"""Order service function handler for package tests.""" + + +def handler(event, context): + """Lambda handler for order.service function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from order.service", + "service": "order.service" + } + } diff --git a/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/template.yaml b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/template.yaml new file mode 100644 index 00000000000..8a5d351e946 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/template.yaml @@ -0,0 +1,52 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach and invalid Mapping key characters. + This template uses collection values containing special characters that are invalid + for CloudFormation Mapping keys (dots, slashes, spaces, etc.). + + CloudFormation Mapping keys can only contain alphanumeric characters (a-z, A-Z, 0-9), + hyphens (-), and underscores (_). + + This template should fail with a clear error message about invalid Mapping key characters. + +Parameters: + Runtime: + Type: String + Default: python3.12 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: !Ref Runtime + +Resources: + # Test Fn::ForEach with collection values containing invalid characters for Mapping keys + # "user-service" is valid (hyphens are allowed) + # "order.service" is INVALID (dots are not allowed) + # This should fail with a clear error message + Fn::ForEach::Services: + - Name + - - user-service + - order.service + - ${Name}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ${Name}/ + Environment: + Variables: + SERVICE_NAME: !Sub ${Name} + +Outputs: + UserServiceFunctionArn: + Description: ARN of the user-service function + Value: !GetAtt user-serviceFunction.Arn + OrderServiceFunctionArn: + Description: ARN of the order.service function + Value: !GetAtt order.serviceFunction.Arn diff --git a/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/user-service/main.py b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/user-service/main.py new file mode 100644 index 00000000000..702fa19bb32 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-invalid-mapping-keys/user-service/main.py @@ -0,0 +1,12 @@ +"""User service function handler for package tests.""" + + +def handler(event, context): + """Lambda handler for user-service function.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from user-service", + "service": "user-service" + } + } diff --git a/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py new file mode 100644 index 00000000000..8bcbb778c4d --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Orders/main.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"statusCode": 200, "body": {"service": "Orders"}} diff --git a/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py new file mode 100644 index 00000000000..51d10bef503 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/services/Users/main.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"statusCode": 200, "body": {"service": "Users"}} diff --git a/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/template.yaml b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/template.yaml new file mode 100644 index 00000000000..e8ec3a77437 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-nested-foreach-dynamic-codeuri/template.yaml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for nested Fn::ForEach with dynamic CodeUri for package tests. + +Resources: + Fn::ForEach::Environments: + - Env + - - dev + - prod + - Fn::ForEach::Services: + - Service + - - Users + - Orders + - ${Env}${Service}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./services/${Service} + Runtime: python3.13 diff --git a/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Orders/main.py b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Orders/main.py new file mode 100644 index 00000000000..2553eea7c37 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Orders/main.py @@ -0,0 +1,12 @@ +"""Orders service handler for package tests.""" + + +def handler(event, context): + """Lambda handler for Orders service.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Orders service", + "service": "OrdersService" + } + } diff --git a/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Users/main.py b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Users/main.py new file mode 100644 index 00000000000..964da7fe8a8 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/Users/main.py @@ -0,0 +1,12 @@ +"""Users service handler for package tests.""" + + +def handler(event, context): + """Lambda handler for Users service.""" + return { + "statusCode": 200, + "body": { + "message": "Hello from Users service", + "service": "UsersService" + } + } diff --git a/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/template.yaml b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/template.yaml new file mode 100644 index 00000000000..4e857c75db1 --- /dev/null +++ b/tests/integration/testdata/package/language-extensions-param-dynamic-codeuri/template.yaml @@ -0,0 +1,54 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for sam package with Fn::ForEach, parameter-based collection, + and dynamic CodeUri. This template uses !Ref ServiceNames as the collection + with dynamic CodeUri (CodeUri: ${Name}/), which should emit a warning that + collection values are fixed at package time. + +Parameters: + ServiceNames: + Type: CommaDelimitedList + Default: Users,Orders + Description: List of service names to generate Lambda functions for + Runtime: + Type: String + Default: python3.12 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: !Ref Runtime + +Resources: + # Test Fn::ForEach with parameter-based collection and dynamic CodeUri + # This generates UsersService and OrdersService (based on default parameter value) + # UsersService -> CodeUri: Users/ + # OrdersService -> CodeUri: Orders/ + # + # Since the collection is parameter-based (!Ref ServiceNames) and CodeUri is dynamic, + # sam package should emit a warning that collection values are fixed at package time. + Fn::ForEach::Services: + - Name + - !Ref ServiceNames + - ${Name}Service: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ${Name}/ + Environment: + Variables: + SERVICE_NAME: !Sub ${Name} + +Outputs: + UsersServiceArn: + Description: ARN of the Users service function + Value: !GetAtt UsersService.Arn + OrdersServiceArn: + Description: ARN of the Orders service function + Value: !GetAtt OrdersService.Arn diff --git a/tests/unit/commands/deploy/test_deploy_context.py b/tests/unit/commands/deploy/test_deploy_context.py index 179687a95ef..844d5138d19 100644 --- a/tests/unit/commands/deploy/test_deploy_context.py +++ b/tests/unit/commands/deploy/test_deploy_context.py @@ -275,3 +275,167 @@ def test_on_failure_do_nothing(self, mock_session, mock_client): self.deploy_command_context.deployer.wait_for_execute.assert_called_with( ANY, "CREATE", False, FailureMode.DO_NOTHING, 1000, 60 ) + + +class TestDeployContextLanguageExtensions(TestCase): + """Test cases for language extensions support in DeployContext""" + + @patch("boto3.Session") + @patch("boto3.client") + @patch("samcli.commands.deploy.deploy_context.auth_per_resource") + @patch("samcli.commands.deploy.deploy_context.SamLocalStackProvider.get_stacks") + @patch.object(Deployer, "create_and_wait_for_changeset", MagicMock(return_value=({"Id": "test"}, "CREATE"))) + @patch.object(Deployer, "execute_changeset", MagicMock()) + @patch.object(Deployer, "wait_for_execute", MagicMock()) + def test_deploy_preserves_foreach_structure( + self, patched_get_stacks, patched_auth_required, mock_client, mock_session + ): + """Test that sam deploy passes the original template with Fn::ForEach intact to CloudFormation""" + patched_get_stacks.return_value = (Mock(), []) + patched_auth_required.return_value = [] + + # Template with Fn::ForEach structure + template_content = """ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 +Resources: + Fn::ForEach::Functions: + - Name + - [Alpha, Beta] + - ${Name}Function: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://bucket/code.zip + Handler: ${Name}.handler + Runtime: python3.9 +""" + + deploy_context = DeployContext( + template_file="template-file", + stack_name="stack-name", + s3_bucket="s3-bucket", + image_repository=None, + image_repositories=None, + force_upload=True, + no_progressbar=False, + s3_prefix="s3-prefix", + kms_key_id=None, + parameter_overrides={}, + capabilities="CAPABILITY_IAM", + no_execute_changeset=False, + role_arn=None, + notification_arns=[], + fail_on_empty_changeset=False, + tags={}, + region="us-east-1", + profile=None, + confirm_changeset=False, + signing_profiles=None, + use_changeset=True, + disable_rollback=False, + poll_delay=0.5, + on_failure=None, + max_wait_duration=60, + ) + + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as template_file: + template_file.write(template_content) + template_file.flush() + deploy_context.template_file = template_file.name + + deploy_context.run() + + # Verify that create_and_wait_for_changeset was called with the original template + # containing Fn::ForEach (not expanded) + call_args = deploy_context.deployer.create_and_wait_for_changeset.call_args + cfn_template = call_args[1]["cfn_template"] + + # The template should contain Fn::ForEach::Functions (not expanded) + self.assertIn("Fn::ForEach::Functions", cfn_template) + self.assertIn("${Name}Function", cfn_template) + self.assertIn("${Name}.handler", cfn_template) + + # The template should NOT contain expanded function names + self.assertNotIn("AlphaFunction:", cfn_template) + self.assertNotIn("BetaFunction:", cfn_template) + + @patch("boto3.Session") + @patch("boto3.client") + @patch("samcli.commands.deploy.deploy_context.auth_per_resource") + @patch("samcli.commands.deploy.deploy_context.SamLocalStackProvider.get_stacks") + @patch.object(Deployer, "sync", MagicMock()) + def test_sync_preserves_foreach_structure( + self, patched_get_stacks, patched_auth_required, mock_client, mock_session + ): + """Test that sam sync passes the original template with Fn::ForEach intact to CloudFormation""" + patched_get_stacks.return_value = (Mock(), []) + patched_auth_required.return_value = [] + + # Template with Fn::ForEach structure + template_content = """ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 +Resources: + Fn::ForEach::Functions: + - Name + - [Alpha, Beta] + - ${Name}Function: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://bucket/code.zip + Handler: ${Name}.handler + Runtime: python3.9 +""" + + sync_context = DeployContext( + template_file="template-file", + stack_name="stack-name", + s3_bucket="s3-bucket", + image_repository=None, + image_repositories=None, + force_upload=True, + no_progressbar=False, + s3_prefix="s3-prefix", + kms_key_id=None, + parameter_overrides={}, + capabilities="CAPABILITY_IAM", + no_execute_changeset=False, + role_arn=None, + notification_arns=[], + fail_on_empty_changeset=False, + tags={}, + region="us-east-1", + profile=None, + confirm_changeset=False, + signing_profiles=None, + use_changeset=False, # Use sync instead of changeset + disable_rollback=False, + poll_delay=0.5, + on_failure=None, + max_wait_duration=60, + ) + + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as template_file: + template_file.write(template_content) + template_file.flush() + sync_context.template_file = template_file.name + + sync_context.run() + + # Verify that sync was called with the original template + # containing Fn::ForEach (not expanded) + call_args = sync_context.deployer.sync.call_args + cfn_template = call_args[1]["cfn_template"] + + # The template should contain Fn::ForEach::Functions (not expanded) + self.assertIn("Fn::ForEach::Functions", cfn_template) + self.assertIn("${Name}Function", cfn_template) + self.assertIn("${Name}.handler", cfn_template) + + # The template should NOT contain expanded function names + self.assertNotIn("AlphaFunction:", cfn_template) + self.assertNotIn("BetaFunction:", cfn_template) diff --git a/tests/unit/commands/deploy/test_exceptions.py b/tests/unit/commands/deploy/test_exceptions.py new file mode 100644 index 00000000000..dd19430d317 --- /dev/null +++ b/tests/unit/commands/deploy/test_exceptions.py @@ -0,0 +1,159 @@ +""" +Unit tests for deploy exceptions +""" + +from unittest import TestCase + +from samcli.commands.deploy.exceptions import ( + parse_findmap_error, + MissingMappingKeyError, + DeployFailedError, +) + + +class TestParseFindmapError(TestCase): + """Tests for the parse_findmap_error function""" + + def test_parse_findmap_error_with_single_quotes(self): + """Test parsing error message with single quotes around key and mapping name""" + error_message = "Fn::FindInMap - Key 'Products' not found in Mapping 'SAMCodeUriServices'" + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], "Products") + self.assertEqual(result[1], "SAMCodeUriServices") + + def test_parse_findmap_error_with_double_quotes(self): + """Test parsing error message with double quotes around key and mapping name""" + error_message = 'Fn::FindInMap - Key "Products" not found in Mapping "SAMCodeUriServices"' + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], "Products") + self.assertEqual(result[1], "SAMCodeUriServices") + + def test_parse_findmap_error_without_quotes(self): + """Test parsing error message without quotes around key and mapping name""" + error_message = "Fn::FindInMap - Key Products not found in Mapping SAMCodeUriServices" + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], "Products") + self.assertEqual(result[1], "SAMCodeUriServices") + + def test_parse_findmap_error_with_waiter_error_wrapper(self): + """Test parsing error message wrapped in WaiterError format""" + error_message = ( + "Waiter StackCreateComplete failed: Waiter encountered a terminal failure state: " + 'For expression "Stacks[].StackStatus" we matched expected path: "CREATE_FAILED" ' + "at least once. Resource handler returned message: \"Fn::FindInMap - Key 'NewService' " + "not found in Mapping 'SAMCodeUriMyLoop'\" (RequestToken: abc123)" + ) + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], "NewService") + self.assertEqual(result[1], "SAMCodeUriMyLoop") + + def test_parse_findmap_error_with_different_mapping_names(self): + """Test parsing error message with various mapping name patterns""" + test_cases = [ + ("Fn::FindInMap - Key 'Alpha' not found in Mapping 'SAMContentUriLayers'", "Alpha", "SAMContentUriLayers"), + ( + "Fn::FindInMap - Key 'Beta' not found in Mapping 'SAMDefinitionUriAPIs'", + "Beta", + "SAMDefinitionUriAPIs", + ), + ("Fn::FindInMap - Key 'Gamma' not found in Mapping 'CustomMapping'", "Gamma", "CustomMapping"), + ] + for error_message, expected_key, expected_mapping in test_cases: + with self.subTest(error_message=error_message): + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], expected_key) + self.assertEqual(result[1], expected_mapping) + + def test_parse_findmap_error_returns_none_for_non_matching_error(self): + """Test that non-matching error messages return None""" + non_matching_errors = [ + "Some other CloudFormation error", + "Resource creation failed", + "Invalid template format", + "Stack already exists", + "", + ] + for error_message in non_matching_errors: + with self.subTest(error_message=error_message): + result = parse_findmap_error(error_message) + self.assertIsNone(result) + + def test_parse_findmap_error_with_special_characters_in_key(self): + """Test parsing error message with special characters in key name""" + # Keys with hyphens + error_message = "Fn::FindInMap - Key 'user-service' not found in Mapping 'SAMCodeUriServices'" + result = parse_findmap_error(error_message) + self.assertIsNotNone(result) + self.assertEqual(result[0], "user-service") + self.assertEqual(result[1], "SAMCodeUriServices") + + +class TestMissingMappingKeyError(TestCase): + """Tests for the MissingMappingKeyError exception""" + + def test_missing_mapping_key_error_message_format(self): + """Test that the error message is formatted correctly""" + error = MissingMappingKeyError( + stack_name="my-stack", + missing_key="Products", + mapping_name="SAMCodeUriServices", + original_error="Fn::FindInMap - Key 'Products' not found in Mapping 'SAMCodeUriServices'", + ) + + # Check that key information is in the message + self.assertIn("my-stack", str(error)) + self.assertIn("Products", str(error)) + self.assertIn("SAMCodeUriServices", str(error)) + + # Check that helpful guidance is included + self.assertIn("sam package", str(error)) + self.assertIn("sam deploy", str(error)) + self.assertIn("parameter values", str(error)) + + # Check that the original error is preserved + self.assertIn("Original CloudFormation error", str(error)) + + def test_missing_mapping_key_error_attributes(self): + """Test that the error attributes are set correctly""" + error = MissingMappingKeyError( + stack_name="test-stack", + missing_key="NewValue", + mapping_name="SAMCodeUriLoop", + original_error="original error message", + ) + + self.assertEqual(error.stack_name, "test-stack") + self.assertEqual(error.missing_key, "NewValue") + self.assertEqual(error.mapping_name, "SAMCodeUriLoop") + self.assertEqual(error.original_error, "original error message") + + def test_missing_mapping_key_error_suggests_repackaging(self): + """Test that the error message suggests re-running sam package""" + error = MissingMappingKeyError( + stack_name="my-stack", + missing_key="NewService", + mapping_name="SAMCodeUriServices", + original_error="test error", + ) + + message = str(error) + self.assertIn("Re-run 'sam package'", message) + self.assertIn("--parameter-overrides", message) + + def test_missing_mapping_key_error_explains_foreach_constraint(self): + """Test that the error message explains the Fn::ForEach constraint""" + error = MissingMappingKeyError( + stack_name="my-stack", + missing_key="NewService", + mapping_name="SAMCodeUriServices", + original_error="test error", + ) + + message = str(error) + self.assertIn("Fn::ForEach", message) + self.assertIn("package time", message) diff --git a/tests/unit/commands/package/test_package_context.py b/tests/unit/commands/package/test_package_context.py index 5449381b4fe..7d96b69371f 100644 --- a/tests/unit/commands/package/test_package_context.py +++ b/tests/unit/commands/package/test_package_context.py @@ -8,6 +8,10 @@ from samcli.commands.package.package_context import PackageContext from samcli.commands.package.exceptions import PackageFailedError +from samcli.lib.cfn_language_extensions.sam_integration import ( + contains_loop_variable, + detect_dynamic_artifact_properties, +) from samcli.lib.package.artifact_exporter import Template from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider from samcli.lib.samlib.resource_metadata_normalizer import ResourceMetadataNormalizer @@ -429,3 +433,4515 @@ def test_package_with_resolve_image_repos(self, patched_boto, mock_get_validated self.assertEqual(call_args[0][0], temp_template_file.name) # Check that s3_bucket was passed self.assertEqual(call_args[0][3], "s3-bucket") + + +class TestPackageContextLanguageExtensions(TestCase): + """Test cases for language extensions support in PackageContext""" + + def test_check_using_language_extension_string_transform(self): + """Test detection of language extensions as a string transform""" + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + template = {"Transform": "AWS::LanguageExtensions"} + self.assertTrue(check_using_language_extension(template)) + + def test_check_using_language_extension_list_transform(self): + """Test detection of language extensions in a list of transforms""" + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + template = {"Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"]} + self.assertTrue(check_using_language_extension(template)) + + def test_check_using_language_extension_no_transform(self): + """Test that templates without language extensions return False""" + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + template = {"Transform": "AWS::Serverless-2016-10-31"} + self.assertFalse(check_using_language_extension(template)) + + def test_check_using_language_extension_none_template(self): + """Test that None template returns False""" + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + self.assertFalse(check_using_language_extension(None)) + + def test_check_using_language_extension_empty_template(self): + """Test that empty template returns False""" + from samcli.lib.cfn_language_extensions.sam_integration import check_using_language_extension + + self.assertFalse(check_using_language_extension({})) + + def test_update_original_template_with_s3_uris_preserves_foreach(self): + """Test that Fn::ForEach structure is preserved when updating S3 URIs""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "${Name}.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Alpha.handler", + "Runtime": "python3.9", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Beta.handler", + "Runtime": "python3.9", + }, + }, + }, + } + + result = package_context._update_original_template_with_s3_uris(original_template, exported_template) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Functions", result["Resources"]) + + # Verify S3 URI is updated in the Fn::ForEach body + foreach_body = result["Resources"]["Fn::ForEach::Functions"][2] + self.assertEqual( + foreach_body["${Name}Function"]["Properties"]["CodeUri"], + "s3://bucket/abc123.zip", + ) + + # Verify other properties are preserved + self.assertEqual( + foreach_body["${Name}Function"]["Properties"]["Handler"], + "${Name}.handler", + ) + + def test_update_original_template_with_s3_uris_regular_resources(self): + """Test that regular resources (non-ForEach) are also updated correctly""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "app.handler", + "Runtime": "python3.9", + }, + } + }, + } + + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/xyz789.zip", + "Handler": "app.handler", + "Runtime": "python3.9", + }, + } + }, + } + + result = package_context._update_original_template_with_s3_uris(original_template, exported_template) + + # Verify S3 URI is updated + self.assertEqual( + result["Resources"]["MyFunction"]["Properties"]["CodeUri"], + "s3://bucket/xyz789.zip", + ) + + def test_copy_artifact_uris_for_type_serverless_function(self): + """Test copying artifact URIs for AWS::Serverless::Function""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_props = {"CodeUri": "./src", "Handler": "app.handler"} + exported_props = {"CodeUri": "s3://bucket/code.zip", "Handler": "app.handler"} + + result = package_context._copy_artifact_uris_for_type( + original_props, exported_props, "AWS::Serverless::Function" + ) + + self.assertTrue(result) + self.assertEqual(original_props["CodeUri"], "s3://bucket/code.zip") + + def test_copy_artifact_uris_for_type_lambda_function(self): + """Test copying artifact URIs for AWS::Lambda::Function""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_props = {"Code": "./src", "Handler": "app.handler"} + exported_props = { + "Code": {"S3Bucket": "bucket", "S3Key": "code.zip"}, + "Handler": "app.handler", + } + + result = package_context._copy_artifact_uris_for_type(original_props, exported_props, "AWS::Lambda::Function") + + self.assertTrue(result) + self.assertEqual(original_props["Code"], {"S3Bucket": "bucket", "S3Key": "code.zip"}) + + def test_copy_artifact_uris_for_type_serverless_layer(self): + """Test copying artifact URIs for AWS::Serverless::LayerVersion""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_props = {"ContentUri": "./layer"} + exported_props = {"ContentUri": "s3://bucket/layer.zip"} + + result = package_context._copy_artifact_uris_for_type( + original_props, exported_props, "AWS::Serverless::LayerVersion" + ) + + self.assertTrue(result) + self.assertEqual(original_props["ContentUri"], "s3://bucket/layer.zip") + + def test_copy_artifact_uris_for_type_serverless_api(self): + """Test copying artifact URIs for AWS::Serverless::Api""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_props = {"DefinitionUri": "./api.yaml"} + exported_props = {"DefinitionUri": "s3://bucket/api.yaml"} + + result = package_context._copy_artifact_uris_for_type(original_props, exported_props, "AWS::Serverless::Api") + + self.assertTrue(result) + self.assertEqual(original_props["DefinitionUri"], "s3://bucket/api.yaml") + + def test_copy_artifact_uris_for_type_unknown_type(self): + """Test that unknown resource types return False""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_props = {"SomeProperty": "value"} + exported_props = {"SomeProperty": "s3://bucket/value"} + + result = package_context._copy_artifact_uris_for_type(original_props, exported_props, "AWS::Unknown::Resource") + + self.assertFalse(result) + + def test_update_foreach_with_s3_uris_multiple_resource_types(self): + """Test updating Fn::ForEach with multiple resource types""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + foreach_value = [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "${Name}.handler", + }, + }, + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./layer", + }, + }, + }, + ] + + exported_resources = { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/func.zip", + "Handler": "Alpha.handler", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/func.zip", + "Handler": "Beta.handler", + }, + }, + "AlphaLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "s3://bucket/layer.zip", + }, + }, + "BetaLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "s3://bucket/layer.zip", + }, + }, + } + + package_context._update_foreach_with_s3_uris("Fn::ForEach::Resources", foreach_value, exported_resources) + + # Verify both resource types have updated S3 URIs + body = foreach_value[2] + self.assertEqual(body["${Name}Function"]["Properties"]["CodeUri"], "s3://bucket/func.zip") + self.assertEqual(body["${Name}Layer"]["Properties"]["ContentUri"], "s3://bucket/layer.zip") + + def test_update_foreach_with_s3_uris_invalid_foreach_structure(self): + """Test that invalid Fn::ForEach structures are handled gracefully""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + # Test with invalid structure (not a list) + package_context._update_foreach_with_s3_uris("Fn::ForEach::Test", "invalid", {}) + + # Test with list that's too short + package_context._update_foreach_with_s3_uris("Fn::ForEach::Test", ["Name", ["Alpha"]], {}) + + # Test with non-dict body + package_context._update_foreach_with_s3_uris("Fn::ForEach::Test", ["Name", ["Alpha"], "invalid"], {}) + + # No exceptions should be raised + + def test_replace_dynamic_artifact_with_findmap_basic(self): + """Test basic replacement of dynamic artifact property with Fn::FindInMap""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + resources = { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders", "Products"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders", "Products"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + self.assertTrue(result) + + # Verify the property was replaced with Fn::FindInMap + body = resources["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + self.assertIsInstance(code_uri, dict) + self.assertIn("Fn::FindInMap", code_uri) + self.assertEqual(code_uri["Fn::FindInMap"], ["SAMCodeUriServices", {"Ref": "Name"}, "CodeUri"]) + + def test_replace_dynamic_artifact_with_findmap_preserves_foreach_structure(self): + """Test that Fn::ForEach structure is preserved after replacement""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + resources = { + "Fn::ForEach::Functions": [ + "FuncName", + ["Alpha", "Beta"], + { + "${FuncName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./${FuncName}", + "Handler": "${FuncName}.handler", + "Runtime": "python3.9", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Functions", + loop_name="Functions", + loop_variable="FuncName", + collection=["Alpha", "Beta"], + resource_key="${FuncName}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${FuncName}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Functions", resources) + foreach_value = resources["Fn::ForEach::Functions"] + self.assertIsInstance(foreach_value, list) + self.assertEqual(len(foreach_value), 3) + self.assertEqual(foreach_value[0], "FuncName") + self.assertEqual(foreach_value[1], ["Alpha", "Beta"]) + + # Verify other properties are preserved + body = foreach_value[2] + self.assertEqual(body["${FuncName}Function"]["Properties"]["Handler"], "${FuncName}.handler") + self.assertEqual(body["${FuncName}Function"]["Properties"]["Runtime"], "python3.9") + + def test_replace_dynamic_artifact_with_findmap_invalid_foreach_key(self): + """Test that invalid Fn::ForEach key returns False""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + resources = {} # Empty resources - foreach_key won't be found + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::NonExistent", + loop_name="NonExistent", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Resource", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + self.assertFalse(result) + + def test_replace_dynamic_artifact_with_findmap_invalid_foreach_structure(self): + """Test that invalid Fn::ForEach structure returns False""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + # Test with invalid structure (not a list) + resources = {"Fn::ForEach::Test": "invalid"} + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Test", + loop_name="Test", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Resource", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + # Test with list that's too short + resources = {"Fn::ForEach::Test": ["Name", ["A"]]} + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + def test_replace_dynamic_artifact_with_findmap_missing_resource_key(self): + """Test that missing resource key in body returns False""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + resources = { + "Fn::ForEach::Test": [ + "Name", + ["A", "B"], + { + "${Name}OtherResource": { # Different resource key + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Test", + loop_name="Test", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Resource", # This key doesn't exist in body + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + def test_replace_dynamic_artifact_with_findmap_content_uri(self): + """Test replacement for ContentUri property (LayerVersion)""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + resources = { + "Fn::ForEach::Layers": [ + "LayerName", + ["Common", "Utils"], + { + "${LayerName}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./layers/${LayerName}", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Layers", + loop_name="Layers", + loop_variable="LayerName", + collection=["Common", "Utils"], + resource_key="${LayerName}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${LayerName}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + self.assertTrue(result) + + # Verify the property was replaced with Fn::FindInMap + body = resources["Fn::ForEach::Layers"][2] + content_uri = body["${LayerName}Layer"]["Properties"]["ContentUri"] + self.assertIsInstance(content_uri, dict) + self.assertIn("Fn::FindInMap", content_uri) + self.assertEqual(content_uri["Fn::FindInMap"], ["SAMContentUriLayers", {"Ref": "LayerName"}, "ContentUri"]) + + def test_apply_artifact_mappings_to_template_integration(self): + """Test full integration of _apply_artifact_mappings_to_template with _replace_dynamic_artifact_with_findmap""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + mappings = { + "SAMCodeUriServices": { + "Users": {"CodeUri": "s3://bucket/users-abc123.zip"}, + "Orders": {"CodeUri": "s3://bucket/orders-def456.zip"}, + } + } + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + result = package_context._apply_artifact_mappings_to_template(template, mappings, dynamic_properties) + + # Verify Mappings section was added + self.assertIn("Mappings", result) + self.assertIn("SAMCodeUriServices", result["Mappings"]) + self.assertEqual(result["Mappings"]["SAMCodeUriServices"]["Users"]["CodeUri"], "s3://bucket/users-abc123.zip") + self.assertEqual(result["Mappings"]["SAMCodeUriServices"]["Orders"]["CodeUri"], "s3://bucket/orders-def456.zip") + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Services", result["Resources"]) + + # Verify CodeUri was replaced with Fn::FindInMap + body = result["Resources"]["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + self.assertEqual(code_uri, {"Fn::FindInMap": ["SAMCodeUriServices", {"Ref": "Name"}, "CodeUri"]}) + + +class TestPackageContextMappingsIntegration(TestCase): + """Test cases for the complete Mappings transformation integration in _export()""" + + def test_export_with_dynamic_artifact_properties_generates_mappings(self): + """Test that _export() generates Mappings for dynamic artifact properties""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/orders-def456.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + }, + } + + # Detect dynamic properties + dynamic_properties = detect_dynamic_artifact_properties(original_template, package_context.parameter_overrides) + + # Verify dynamic properties were detected + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + self.assertEqual(dynamic_properties[0].loop_variable, "Name") + + # Update original template with S3 URIs (skipping dynamic properties) + output_template = package_context._update_original_template_with_s3_uris( + original_template, exported_template, dynamic_properties + ) + + # Generate Mappings + exported_resources = exported_template.get("Resources", {}) + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Apply Mappings to template + output_template = package_context._apply_artifact_mappings_to_template( + output_template, mappings, dynamic_properties + ) + + # Verify Mappings section was added + self.assertIn("Mappings", output_template) + self.assertIn("SAMCodeUriServices", output_template["Mappings"]) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Services", output_template["Resources"]) + + # Verify CodeUri was replaced with Fn::FindInMap + body = output_template["Resources"]["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + self.assertEqual(code_uri, {"Fn::FindInMap": ["SAMCodeUriServices", {"Ref": "Name"}, "CodeUri"]}) + + # Verify other properties are preserved + self.assertEqual(body["${Name}Service"]["Properties"]["Handler"], "index.handler") + self.assertEqual(body["${Name}Service"]["Properties"]["Runtime"], "python3.9") + + def test_export_with_static_artifact_properties_no_mappings(self): + """Test that _export() does not generate Mappings for static artifact properties""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", # Static - same for all + "Handler": "${Name}.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Alpha.handler", + "Runtime": "python3.9", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Beta.handler", + "Runtime": "python3.9", + }, + }, + }, + } + + # Detect dynamic properties - should be empty for static CodeUri + dynamic_properties = detect_dynamic_artifact_properties(original_template, package_context.parameter_overrides) + + # Verify no dynamic properties were detected + self.assertEqual(len(dynamic_properties), 0) + + # Update original template with S3 URIs + output_template = package_context._update_original_template_with_s3_uris( + original_template, exported_template, dynamic_properties + ) + + # Verify no Mappings section was added (since no dynamic properties) + self.assertNotIn("Mappings", output_template) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Functions", output_template["Resources"]) + + # Verify S3 URI is updated in the Fn::ForEach body + body = output_template["Resources"]["Fn::ForEach::Functions"][2] + self.assertEqual(body["${Name}Function"]["Properties"]["CodeUri"], "s3://bucket/abc123.zip") + + # Verify other properties are preserved + self.assertEqual(body["${Name}Function"]["Properties"]["Handler"], "${Name}.handler") + + def test_export_preserves_foreach_structure_with_multiple_dynamic_properties(self): + """Test that multiple dynamic properties in the same ForEach are handled correctly""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + }, + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./layers/${Name}", + }, + }, + }, + ] + }, + } + + # Detect dynamic properties + dynamic_properties = detect_dynamic_artifact_properties(original_template, package_context.parameter_overrides) + + # Verify both dynamic properties were detected + self.assertEqual(len(dynamic_properties), 2) + property_names = {p.property_name for p in dynamic_properties} + self.assertIn("CodeUri", property_names) + self.assertIn("ContentUri", property_names) + + def test_generate_artifact_mappings_creates_correct_structure(self): + """Test that _generate_artifact_mappings creates the correct Mappings structure""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders", "Products"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + exported_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/orders-def456.zip", + "Handler": "index.handler", + }, + }, + "ProductsService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/products-ghi789.zip", + "Handler": "index.handler", + }, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify Mappings structure + self.assertIn("SAMCodeUriServices", mappings) + self.assertEqual(len(mappings["SAMCodeUriServices"]), 3) + + # Verify each collection value has a mapping entry + self.assertIn("Users", mappings["SAMCodeUriServices"]) + self.assertIn("Orders", mappings["SAMCodeUriServices"]) + self.assertIn("Products", mappings["SAMCodeUriServices"]) + + # Verify S3 URIs are correct + self.assertEqual(mappings["SAMCodeUriServices"]["Users"]["CodeUri"], "s3://bucket/users-abc123.zip") + self.assertEqual(mappings["SAMCodeUriServices"]["Orders"]["CodeUri"], "s3://bucket/orders-def456.zip") + self.assertEqual(mappings["SAMCodeUriServices"]["Products"]["CodeUri"], "s3://bucket/products-ghi789.zip") + + def test_update_original_template_skips_dynamic_properties(self): + """Test that _update_original_template_with_s3_uris skips dynamic properties""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", # Dynamic - should be skipped + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/orders-def456.zip", + "Handler": "index.handler", + }, + }, + }, + } + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + result = package_context._update_original_template_with_s3_uris( + original_template, exported_template, dynamic_properties + ) + + # Verify the dynamic CodeUri property was NOT updated (still has original value) + body = result["Resources"]["Fn::ForEach::Services"][2] + self.assertEqual(body["${Name}Service"]["Properties"]["CodeUri"], "./services/${Name}") + + def test_detect_dynamic_artifact_properties_with_fn_sub(self): + """Test detection of dynamic properties using Fn::Sub""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": "./services/${Name}"}, + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # Verify dynamic property was detected even with Fn::Sub + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + + def test_detect_dynamic_artifact_properties_with_parameter_collection(self): + """Test detection of dynamic properties with parameter-based collection""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + parameter_overrides={"ServiceNames": "Users,Orders,Products"}, + ) + + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": { + "ServiceNames": { + "Type": "CommaDelimitedList", + "Default": "Users,Orders", + } + }, + "Resources": { + "Fn::ForEach::Services": [ + "Name", + {"Ref": "ServiceNames"}, + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # Verify dynamic property was detected + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + + # Verify collection was resolved from parameter_overrides + self.assertEqual(dynamic_properties[0].collection, ["Users", "Orders", "Products"]) + + def test_contains_loop_variable_nested_structures(self): + """Test contains_loop_variable with nested structures""" + # Test simple string + self.assertTrue(contains_loop_variable("./src/${Name}", "Name")) + self.assertFalse(contains_loop_variable("./src", "Name")) + + # Test Fn::Sub string + self.assertTrue(contains_loop_variable({"Fn::Sub": "./src/${Name}"}, "Name")) + self.assertFalse(contains_loop_variable({"Fn::Sub": "./src"}, "Name")) + + # Test Fn::Sub list format + self.assertTrue(contains_loop_variable({"Fn::Sub": ["./src/${Name}", {}]}, "Name")) + + # Test nested dict + self.assertTrue(contains_loop_variable({"key": {"nested": "./src/${Name}"}}, "Name")) + + # Test list + self.assertTrue(contains_loop_variable(["./src/${Name}", "other"], "Name")) + + def test_substitute_loop_variable(self): + """Test substitute_loop_variable correctly substitutes values""" + from samcli.lib.cfn_language_extensions.sam_integration import substitute_loop_variable + + # Test basic substitution + result = substitute_loop_variable("${Name}Service", "Name", "Users") + self.assertEqual(result, "UsersService") + + # Test multiple occurrences + result = substitute_loop_variable("${Name}/${Name}", "Name", "Test") + self.assertEqual(result, "Test/Test") + + # Test no substitution needed + result = substitute_loop_variable("StaticValue", "Name", "Users") + self.assertEqual(result, "StaticValue") + + def test_find_artifact_uri_for_resource(self): + """Test _find_artifact_uri_for_resource finds correct artifact URIs for all formats""" + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + exported_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + }, + }, + "UsersLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "s3://bucket/layer-xyz789.zip", + }, + }, + "LambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": {"S3Bucket": "bucket", "S3Key": "lambda-code.zip"}, + "Handler": "index.handler", + }, + }, + # Format 3: {Bucket, Key} dict format + "StateMachine": { + "Type": "AWS::Serverless::StateMachine", + "Properties": { + "DefinitionUri": {"Bucket": "bucket", "Key": "statemachine-def.json"}, + }, + }, + "ApiGateway": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "BodyS3Location": {"Bucket": "bucket", "Key": "api-spec.yaml"}, + }, + }, + # Format 4: {ImageUri} dict format (ECR) + "ContainerFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "ImageUri": {"ImageUri": "123456789.dkr.ecr.us-east-1.amazonaws.com/repo:tag"}, + "PackageType": "Image", + }, + }, + } + + # Test Format 1: String URI for Serverless Function CodeUri + result = package_context._find_artifact_uri_for_resource( + exported_resources, "UsersService", "AWS::Serverless::Function", "CodeUri" + ) + self.assertEqual(result, "s3://bucket/users-abc123.zip") + + # Test Format 1: String URI for LayerVersion ContentUri + result = package_context._find_artifact_uri_for_resource( + exported_resources, "UsersLayer", "AWS::Serverless::LayerVersion", "ContentUri" + ) + self.assertEqual(result, "s3://bucket/layer-xyz789.zip") + + # Test Format 2: {S3Bucket, S3Key} dict for Lambda Function Code + result = package_context._find_artifact_uri_for_resource( + exported_resources, "LambdaFunction", "AWS::Lambda::Function", "Code" + ) + self.assertEqual(result, "s3://bucket/lambda-code.zip") + + # Test Format 3: {Bucket, Key} dict for StateMachine DefinitionUri + result = package_context._find_artifact_uri_for_resource( + exported_resources, "StateMachine", "AWS::Serverless::StateMachine", "DefinitionUri" + ) + self.assertEqual(result, "s3://bucket/statemachine-def.json") + + # Test Format 3: {Bucket, Key} dict for API Gateway BodyS3Location + result = package_context._find_artifact_uri_for_resource( + exported_resources, "ApiGateway", "AWS::ApiGateway::RestApi", "BodyS3Location" + ) + self.assertEqual(result, "s3://bucket/api-spec.yaml") + + # Test Format 4: {ImageUri} dict for ECR container images + result = package_context._find_artifact_uri_for_resource( + exported_resources, "ContainerFunction", "AWS::Serverless::Function", "ImageUri" + ) + self.assertEqual(result, "123456789.dkr.ecr.us-east-1.amazonaws.com/repo:tag") + + # Test resource not found + result = package_context._find_artifact_uri_for_resource( + exported_resources, "NonExistent", "AWS::Serverless::Function", "CodeUri" + ) + self.assertIsNone(result) + + # Test wrong resource type + result = package_context._find_artifact_uri_for_resource( + exported_resources, "UsersService", "AWS::Lambda::Function", "Code" + ) + self.assertIsNone(result) + + +class TestDynamicArtifactPropertyDetection(TestCase): + """ + Comprehensive unit tests for dynamic artifact property detection. + + **Validates: Requirements 15.11** + + These tests verify: + 1. Detection of loop variable in CodeUri + 2. Detection in nested Fn::Sub structures + 3. Static properties are NOT flagged as dynamic + 4. All packageable resource types are tested + """ + + def _create_package_context(self, parameter_overrides=None): + """Helper to create a PackageContext instance for testing.""" + return PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + parameter_overrides=parameter_overrides, + ) + + # ========================================================================= + # Tests for loop variable detection in CodeUri + # ========================================================================= + + def test_detect_loop_variable_in_codeuri_simple_string(self): + """Test detection of loop variable in simple string CodeUri""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + self.assertEqual(prop.loop_variable, "Name") + self.assertEqual(prop.property_value, "./services/${Name}") + self.assertEqual(prop.collection, ["Users", "Orders"]) + + def test_detect_loop_variable_in_codeuri_with_prefix_and_suffix(self): + """Test detection of loop variable in CodeUri with prefix and suffix""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "FuncName", + ["Alpha", "Beta", "Gamma"], + { + "${FuncName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src/functions/${FuncName}/code", + "Handler": "app.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + self.assertEqual(prop.loop_variable, "FuncName") + self.assertEqual(prop.property_value, "./src/functions/${FuncName}/code") + + # ========================================================================= + # Tests for nested Fn::Sub structures + # ========================================================================= + + def test_detect_loop_variable_in_fn_sub_string(self): + """Test detection of loop variable in Fn::Sub string format""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": "./services/${Name}"}, + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + + def test_detect_loop_variable_in_fn_sub_list_format(self): + """Test detection of loop variable in Fn::Sub list format""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": ["./services/${Name}", {}]}, + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + + def test_detect_loop_variable_in_nested_fn_sub_with_variables(self): + """Test detection of loop variable in Fn::Sub with variable substitutions""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": ["./services/${Name}/${Env}", {"Env": "prod"}]}, + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + self.assertEqual(prop.property_name, "CodeUri") + + # ========================================================================= + # Tests for static properties NOT being flagged as dynamic + # ========================================================================= + + def test_static_codeuri_not_flagged_as_dynamic(self): + """Test that static CodeUri is NOT flagged as dynamic""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", # Static - no loop variable + "Handler": "${Name}.handler", # Dynamic handler is OK + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # No dynamic artifact properties should be detected + self.assertEqual(len(dynamic_properties), 0) + + def test_static_fn_sub_not_flagged_as_dynamic(self): + """Test that Fn::Sub without loop variable is NOT flagged as dynamic""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": {"Fn::Sub": "./src/${AWS::Region}"}, # Uses pseudo-param, not loop var + "Handler": "${Name}.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # No dynamic artifact properties should be detected + self.assertEqual(len(dynamic_properties), 0) + + def test_static_s3_uri_not_flagged_as_dynamic(self): + """Test that S3 URI CodeUri is NOT flagged as dynamic""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://my-bucket/code.zip", # S3 URI - static + "Handler": "${Name}.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # No dynamic artifact properties should be detected + self.assertEqual(len(dynamic_properties), 0) + + def test_mixed_static_and_dynamic_properties(self): + """Test that only dynamic properties are flagged when mixed with static""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", # Dynamic + "Handler": "index.handler", + }, + }, + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./common-layer", # Static + }, + }, + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # Only the dynamic CodeUri should be detected + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::Function") + + # ========================================================================= + # Tests for all packageable resource types + # ========================================================================= + + def test_detect_dynamic_property_aws_serverless_function_codeuri(self): + """Test detection of dynamic CodeUri in AWS::Serverless::Function""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./functions/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::Function") + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + + def test_detect_dynamic_property_aws_serverless_function_imageuri(self): + """Test detection of dynamic ImageUri in AWS::Serverless::Function""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "ImageUri": "./images/${Name}", + "PackageType": "Image", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::Function") + self.assertEqual(dynamic_properties[0].property_name, "ImageUri") + + def test_detect_dynamic_property_aws_lambda_function_code(self): + """Test detection of dynamic Code in AWS::Lambda::Function""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": "./functions/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + "Role": {"Fn::GetAtt": ["LambdaRole", "Arn"]}, + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Lambda::Function") + self.assertEqual(dynamic_properties[0].property_name, "Code") + + def test_detect_dynamic_property_aws_serverless_layerversion_contenturi(self): + """Test detection of dynamic ContentUri in AWS::Serverless::LayerVersion""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Layers": [ + "Name", + ["Common", "Utils"], + { + "${Name}Layer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "./layers/${Name}", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::LayerVersion") + self.assertEqual(dynamic_properties[0].property_name, "ContentUri") + + def test_detect_dynamic_property_aws_lambda_layerversion_content(self): + """Test detection of dynamic Content in AWS::Lambda::LayerVersion""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Layers": [ + "Name", + ["Common", "Utils"], + { + "${Name}Layer": { + "Type": "AWS::Lambda::LayerVersion", + "Properties": { + "Content": "./layers/${Name}", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Lambda::LayerVersion") + self.assertEqual(dynamic_properties[0].property_name, "Content") + + def test_detect_dynamic_property_aws_serverless_api_definitionuri(self): + """Test detection of dynamic DefinitionUri in AWS::Serverless::Api""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::APIs": [ + "Name", + ["Users", "Orders"], + { + "${Name}Api": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "prod", + "DefinitionUri": "./api/${Name}/openapi.yaml", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::Api") + self.assertEqual(dynamic_properties[0].property_name, "DefinitionUri") + + def test_detect_dynamic_property_aws_serverless_httpapi_definitionuri(self): + """Test detection of dynamic DefinitionUri in AWS::Serverless::HttpApi""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::APIs": [ + "Name", + ["Users", "Orders"], + { + "${Name}HttpApi": { + "Type": "AWS::Serverless::HttpApi", + "Properties": { + "StageName": "prod", + "DefinitionUri": "./api/${Name}/openapi.yaml", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::HttpApi") + self.assertEqual(dynamic_properties[0].property_name, "DefinitionUri") + + def test_detect_dynamic_property_aws_serverless_statemachine_definitionuri(self): + """Test detection of dynamic DefinitionUri in AWS::Serverless::StateMachine""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::StateMachines": [ + "Name", + ["Workflow1", "Workflow2"], + { + "${Name}StateMachine": { + "Type": "AWS::Serverless::StateMachine", + "Properties": { + "DefinitionUri": "./statemachines/${Name}/definition.asl.json", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::StateMachine") + self.assertEqual(dynamic_properties[0].property_name, "DefinitionUri") + + def test_detect_dynamic_property_aws_serverless_graphqlapi_schemauri(self): + """Test detection of dynamic SchemaUri in AWS::Serverless::GraphQLApi""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::GraphQLApis": [ + "Name", + ["Users", "Orders"], + { + "${Name}GraphQLApi": { + "Type": "AWS::Serverless::GraphQLApi", + "Properties": { + "SchemaUri": "./graphql/${Name}/schema.graphql", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::GraphQLApi") + self.assertEqual(dynamic_properties[0].property_name, "SchemaUri") + + def test_detect_dynamic_property_aws_serverless_graphqlapi_codeuri(self): + """Test detection of dynamic CodeUri in AWS::Serverless::GraphQLApi""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::GraphQLApis": [ + "Name", + ["Users", "Orders"], + { + "${Name}GraphQLApi": { + "Type": "AWS::Serverless::GraphQLApi", + "Properties": { + "CodeUri": "./graphql/${Name}/resolvers", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::Serverless::GraphQLApi") + self.assertEqual(dynamic_properties[0].property_name, "CodeUri") + + def test_detect_dynamic_property_aws_apigateway_restapi_bodys3location(self): + """Test detection of dynamic BodyS3Location in AWS::ApiGateway::RestApi""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::APIs": [ + "Name", + ["Users", "Orders"], + { + "${Name}RestApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "BodyS3Location": "./api/${Name}/swagger.yaml", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::ApiGateway::RestApi") + self.assertEqual(dynamic_properties[0].property_name, "BodyS3Location") + + def test_detect_dynamic_property_aws_apigatewayv2_api_bodys3location(self): + """Test detection of dynamic BodyS3Location in AWS::ApiGatewayV2::Api""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::APIs": [ + "Name", + ["Users", "Orders"], + { + "${Name}HttpApi": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "BodyS3Location": "./api/${Name}/openapi.yaml", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::ApiGatewayV2::Api") + self.assertEqual(dynamic_properties[0].property_name, "BodyS3Location") + + def test_detect_dynamic_property_aws_stepfunctions_statemachine_definitions3location(self): + """Test detection of dynamic DefinitionS3Location in AWS::StepFunctions::StateMachine""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::StateMachines": [ + "Name", + ["Workflow1", "Workflow2"], + { + "${Name}StateMachine": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionS3Location": "./statemachines/${Name}/definition.json", + "RoleArn": {"Fn::GetAtt": ["StepFunctionsRole", "Arn"]}, + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 1) + self.assertEqual(dynamic_properties[0].resource_type, "AWS::StepFunctions::StateMachine") + self.assertEqual(dynamic_properties[0].property_name, "DefinitionS3Location") + + # ========================================================================= + # Tests for multiple artifact properties in same resource + # ========================================================================= + + def test_detect_multiple_dynamic_properties_in_same_resource(self): + """Test detection of multiple dynamic properties in the same resource type""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::GraphQLApis": [ + "Name", + ["Users", "Orders"], + { + "${Name}GraphQLApi": { + "Type": "AWS::Serverless::GraphQLApi", + "Properties": { + "SchemaUri": "./graphql/${Name}/schema.graphql", + "CodeUri": "./graphql/${Name}/resolvers", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # Both SchemaUri and CodeUri should be detected + self.assertEqual(len(dynamic_properties), 2) + property_names = {p.property_name for p in dynamic_properties} + self.assertIn("SchemaUri", property_names) + self.assertIn("CodeUri", property_names) + + # ========================================================================= + # Tests for edge cases + # ========================================================================= + + def test_no_dynamic_properties_for_non_packageable_resource(self): + """Test that non-packageable resource types are not flagged""" + package_context = self._create_package_context() + + template = { + "Resources": { + "Fn::ForEach::Tables": [ + "Name", + ["Users", "Orders"], + { + "${Name}Table": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "${Name}Table", + "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + # DynamoDB tables don't have packageable artifact properties + self.assertEqual(len(dynamic_properties), 0) + + def test_no_dynamic_properties_when_no_foreach(self): + """Test that templates without Fn::ForEach return no dynamic properties""" + package_context = self._create_package_context() + + template = { + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "index.handler", + }, + } + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 0) + + def test_no_dynamic_properties_when_resources_is_empty(self): + """Test that empty Resources section returns no dynamic properties""" + package_context = self._create_package_context() + + template = {"Resources": {}} + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 0) + + def test_no_dynamic_properties_when_resources_is_missing(self): + """Test that missing Resources section returns no dynamic properties""" + package_context = self._create_package_context() + + template = {"AWSTemplateFormatVersion": "2010-09-09"} + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 0) + + def test_invalid_foreach_structure_returns_no_properties(self): + """Test that invalid Fn::ForEach structure returns no dynamic properties""" + package_context = self._create_package_context() + + # Invalid: Fn::ForEach value is not a list + template = { + "Resources": {"Fn::ForEach::Invalid": "not-a-list"}, + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 0) + + def test_foreach_with_wrong_number_of_elements(self): + """Test that Fn::ForEach with wrong number of elements returns no properties""" + package_context = self._create_package_context() + + # Invalid: Fn::ForEach should have exactly 3 elements + template = { + "Resources": {"Fn::ForEach::Invalid": ["Name", ["Alpha", "Beta"]]}, # Missing body + } + + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + + self.assertEqual(len(dynamic_properties), 0) + + +class TestPackageContextExportIntegration(TestCase): + """Test cases for the complete _export() method integration""" + + @patch("samcli.commands.package.package_context.Template") + @patch("builtins.open", create=True) + @patch("samcli.commands.package.package_context.yaml_parse") + def test_export_with_language_extensions_and_dynamic_properties( + self, mock_yaml_parse, mock_open, mock_template_class + ): + """Test _export() with language extensions and dynamic artifact properties""" + package_context = PackageContext( + template_file="template.yaml", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + # Mock uploaders and code_signer which are set in run() + package_context.uploaders = Mock() + package_context.code_signer = Mock() + + # Mock the original template with dynamic CodeUri + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + mock_yaml_parse.return_value = original_template + + # Mock the exported template (after artifact upload) + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/orders-def456.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + }, + } + mock_template_instance = Mock() + mock_template_instance.export.return_value = exported_template + mock_template_class.return_value = mock_template_instance + + # Mock file open + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + mock_file.read.return_value = "" + + # Call _export + result = package_context._export("template.yaml", use_json=False) + + # Parse the result + from samcli.yamlhelper import yaml_parse as real_yaml_parse + + output_template = real_yaml_parse(result) + + # Verify Mappings section was added + self.assertIn("Mappings", output_template) + self.assertIn("SAMCodeUriServices", output_template["Mappings"]) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Services", output_template["Resources"]) + + # Verify CodeUri was replaced with Fn::FindInMap + body = output_template["Resources"]["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + self.assertEqual(code_uri, {"Fn::FindInMap": ["SAMCodeUriServices", {"Ref": "Name"}, "CodeUri"]}) + + # Verify S3 URIs are in the Mappings + self.assertEqual( + output_template["Mappings"]["SAMCodeUriServices"]["Users"]["CodeUri"], "s3://bucket/users-abc123.zip" + ) + self.assertEqual( + output_template["Mappings"]["SAMCodeUriServices"]["Orders"]["CodeUri"], "s3://bucket/orders-def456.zip" + ) + + @patch("samcli.commands.package.package_context.Template") + @patch("builtins.open", create=True) + @patch("samcli.commands.package.package_context.yaml_parse") + def test_export_with_language_extensions_and_static_properties( + self, mock_yaml_parse, mock_open, mock_template_class + ): + """Test _export() with language extensions and static artifact properties""" + package_context = PackageContext( + template_file="template.yaml", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + # Mock uploaders and code_signer which are set in run() + package_context.uploaders = Mock() + package_context.code_signer = Mock() + + # Mock the original template with static CodeUri + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Functions": [ + "Name", + ["Alpha", "Beta"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", # Static - same for all + "Handler": "${Name}.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + mock_yaml_parse.return_value = original_template + + # Mock the exported template (after artifact upload) + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Alpha.handler", + "Runtime": "python3.9", + }, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "Beta.handler", + "Runtime": "python3.9", + }, + }, + }, + } + mock_template_instance = Mock() + mock_template_instance.export.return_value = exported_template + mock_template_class.return_value = mock_template_instance + + # Mock file open + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + mock_file.read.return_value = "" + + # Call _export + result = package_context._export("template.yaml", use_json=False) + + # Parse the result + from samcli.yamlhelper import yaml_parse as real_yaml_parse + + output_template = real_yaml_parse(result) + + # Verify no Mappings section was added (static properties don't need Mappings) + self.assertNotIn("Mappings", output_template) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Functions", output_template["Resources"]) + + # Verify S3 URI is updated in the Fn::ForEach body + body = output_template["Resources"]["Fn::ForEach::Functions"][2] + self.assertEqual(body["${Name}Function"]["Properties"]["CodeUri"], "s3://bucket/abc123.zip") + + # Verify other properties are preserved + self.assertEqual(body["${Name}Function"]["Properties"]["Handler"], "${Name}.handler") + + @patch("samcli.commands.package.package_context.Template") + @patch("builtins.open", create=True) + @patch("samcli.commands.package.package_context.yaml_parse") + def test_export_without_language_extensions(self, mock_yaml_parse, mock_open, mock_template_class): + """Test _export() without language extensions returns exported template as-is""" + package_context = PackageContext( + template_file="template.yaml", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + # Mock uploaders and code_signer which are set in run() + package_context.uploaders = Mock() + package_context.code_signer = Mock() + + # Mock the original template without language extensions + original_template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./src", + "Handler": "app.handler", + "Runtime": "python3.9", + }, + } + }, + } + mock_yaml_parse.return_value = original_template + + # Mock the exported template + exported_template = { + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123.zip", + "Handler": "app.handler", + "Runtime": "python3.9", + }, + } + }, + } + mock_template_instance = Mock() + mock_template_instance.export.return_value = exported_template + mock_template_class.return_value = mock_template_instance + + # Mock file open + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + mock_file.read.return_value = "" + + # Call _export + result = package_context._export("template.yaml", use_json=False) + + # Parse the result + from samcli.yamlhelper import yaml_parse as real_yaml_parse + + output_template = real_yaml_parse(result) + + # Verify the exported template is returned as-is + self.assertEqual(output_template, exported_template) + + +class TestPackageContextParameterBasedCollectionWarning(TestCase): + """Test cases for warning when using parameter-based collections with dynamic artifact properties""" + + def test_detect_foreach_dynamic_properties_with_parameter_ref(self): + """Test that parameter-based collections are detected correctly""" + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Parameters": { + "ServiceNames": { + "Type": "CommaDelimitedList", + "Default": "Users,Orders", + } + }, + "Resources": { + "Fn::ForEach::Services": [ + "Name", + {"Ref": "ServiceNames"}, # Parameter reference + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties( + template, parameter_values={"ServiceNames": "Users,Orders"} + ) + + # Verify dynamic property was detected + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + + # Verify parameter reference was detected + self.assertTrue(prop.collection_is_parameter_ref) + self.assertEqual(prop.collection_parameter_name, "ServiceNames") + + def test_detect_foreach_dynamic_properties_with_static_list(self): + """Test that static list collections are not flagged as parameter references""" + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], # Static list + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties(template) + + # Verify dynamic property was detected + self.assertEqual(len(dynamic_properties), 1) + prop = dynamic_properties[0] + + # Verify it's not flagged as parameter reference + self.assertFalse(prop.collection_is_parameter_ref) + self.assertIsNone(prop.collection_parameter_name) + + @patch("samcli.commands.package.package_context.click") + def test_warn_parameter_based_collections_emits_warning(self, mock_click): + """Test that warning is emitted for parameter-based collections with dynamic properties""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=True, + collection_parameter_name="ServiceNames", + ) + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify warning was emitted + mock_click.secho.assert_called_once() + call_args = mock_click.secho.call_args + warning_msg = call_args[0][0] + + # Verify warning message content + self.assertIn("Fn::ForEach 'Services'", warning_msg) + self.assertIn("dynamic CodeUri", warning_msg) + self.assertIn("!Ref ServiceNames", warning_msg) + self.assertIn("Collection values are fixed at package time", warning_msg) + self.assertIn("re-package", warning_msg) + + # Verify warning color + self.assertEqual(call_args[1]["fg"], "yellow") + + @patch("samcli.commands.package.package_context.click") + def test_warn_parameter_based_collections_no_warning_for_static_list(self, mock_click): + """Test that no warning is emitted for static list collections""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=False, # Static list + collection_parameter_name=None, + ) + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify no warning was emitted + mock_click.secho.assert_not_called() + + @patch("samcli.commands.package.package_context.click") + def test_warn_parameter_based_collections_single_warning_per_loop(self, mock_click): + """Test that only one warning is emitted per ForEach loop even with multiple dynamic properties""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + # Two dynamic properties from the same ForEach loop + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=True, + collection_parameter_name="ServiceNames", + ), + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", # Same loop + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${Name}", + collection_is_parameter_ref=True, + collection_parameter_name="ServiceNames", + ), + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify only one warning was emitted (not two) + self.assertEqual(mock_click.secho.call_count, 1) + + @patch("samcli.commands.package.package_context.click") + def test_warn_parameter_based_collections_multiple_loops(self, mock_click): + """Test that warnings are emitted for each ForEach loop with parameter-based collection""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + # Two dynamic properties from different ForEach loops + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=True, + collection_parameter_name="ServiceNames", + ), + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Layers", # Different loop + loop_name="Layers", + loop_variable="LayerName", + collection=["Common", "Utils"], + resource_key="${LayerName}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${LayerName}", + collection_is_parameter_ref=True, + collection_parameter_name="LayerNames", + ), + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify two warnings were emitted (one per loop) + self.assertEqual(mock_click.secho.call_count, 2) + + @patch("samcli.commands.package.package_context.Template") + @patch("builtins.open", create=True) + @patch("samcli.commands.package.package_context.yaml_parse") + @patch("samcli.commands.package.package_context.click") + def test_export_emits_warning_for_parameter_based_collection( + self, mock_click, mock_yaml_parse, mock_open, mock_template_class + ): + """Test that _export() emits warning for parameter-based collections with dynamic properties""" + package_context = PackageContext( + template_file="template.yaml", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + parameter_overrides={"ServiceNames": "Users,Orders"}, + ) + # Mock uploaders and code_signer which are set in run() + package_context.uploaders = Mock() + package_context.code_signer = Mock() + + # Mock the original template with parameter-based collection + original_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": { + "ServiceNames": { + "Type": "CommaDelimitedList", + "Default": "Users,Orders", + } + }, + "Resources": { + "Fn::ForEach::Services": [ + "Name", + {"Ref": "ServiceNames"}, # Parameter reference + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + mock_yaml_parse.return_value = original_template + + # Mock the exported template (after artifact upload) + exported_template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": { + "ServiceNames": { + "Type": "CommaDelimitedList", + "Default": "Users,Orders", + } + }, + "Resources": { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/users-abc123.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/orders-def456.zip", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + }, + }, + } + mock_template_instance = Mock() + mock_template_instance.export.return_value = exported_template + mock_template_class.return_value = mock_template_instance + + # Mock file open + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + mock_file.read.return_value = "" + + # Call _export + package_context._export("template.yaml", use_json=False) + + # Verify warning was emitted + # Find the secho call with the warning message + warning_calls = [ + call + for call in mock_click.secho.call_args_list + if call[1].get("fg") == "yellow" and "Collection values are fixed at package time" in call[0][0] + ] + self.assertEqual(len(warning_calls), 1) + + # Verify warning message content + warning_msg = warning_calls[0][0][0] + self.assertIn("Fn::ForEach 'Services'", warning_msg) + self.assertIn("!Ref ServiceNames", warning_msg) + + +class TestPackageContextMappingKeyValidation(TestCase): + """Test cases for CloudFormation Mapping key validation in PackageContext""" + + def test_validate_mapping_key_compatibility_valid_alphanumeric(self): + """Test that alphanumeric collection values pass validation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders", "Products123"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + def test_validate_mapping_key_compatibility_valid_with_hyphens(self): + """Test that collection values with hyphens pass validation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user-service", "order-service", "product-api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + def test_validate_mapping_key_compatibility_valid_with_underscores(self): + """Test that collection values with underscores pass validation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user_service", "order_service", "product_api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + def test_validate_mapping_key_compatibility_valid_mixed_characters(self): + """Test that collection values with mixed valid characters pass validation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["User-Service_v1", "Order_API-2", "Product123-test_v2"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + def test_validate_mapping_key_compatibility_invalid_with_dots(self): + """Test that collection values with dots raise InvalidMappingKeyError""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user.service", "order.api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + # Verify error message contains the invalid values + self.assertIn("user.service", str(context.exception)) + self.assertIn("order.api", str(context.exception)) + self.assertIn("Services", str(context.exception)) + + def test_validate_mapping_key_compatibility_invalid_with_slashes(self): + """Test that collection values with slashes raise InvalidMappingKeyError""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user/service", "order/api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + # Verify error message contains the invalid values + self.assertIn("user/service", str(context.exception)) + self.assertIn("order/api", str(context.exception)) + + def test_validate_mapping_key_compatibility_invalid_with_spaces(self): + """Test that collection values with spaces raise InvalidMappingKeyError""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user service", "order api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + # Verify error message contains the invalid values + self.assertIn("user service", str(context.exception)) + self.assertIn("order api", str(context.exception)) + + def test_validate_mapping_key_compatibility_invalid_with_special_chars(self): + """Test that collection values with special characters raise InvalidMappingKeyError""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user@service", "order#api", "product$v1"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + # Verify error message contains the invalid values + self.assertIn("user@service", str(context.exception)) + self.assertIn("order#api", str(context.exception)) + self.assertIn("product$v1", str(context.exception)) + + def test_validate_mapping_key_compatibility_mixed_valid_and_invalid(self): + """Test that only invalid values are reported when mixed with valid values""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["valid-service", "invalid.service", "another_valid", "bad/service"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + error_message = str(context.exception) + # Verify only invalid values are in the error message + self.assertIn("invalid.service", error_message) + self.assertIn("bad/service", error_message) + # Valid values should not be mentioned as invalid + self.assertNotIn('"valid-service"', error_message) + self.assertNotIn('"another_valid"', error_message) + + def test_validate_mapping_key_compatibility_error_message_format(self): + """Test that error message has the expected format with helpful guidance""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::MyLoop", + loop_name="MyLoop", + loop_variable="Item", + collection=["bad.value"], + resource_key="${Item}Resource", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Item}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + error_message = str(context.exception) + # Verify error message contains helpful information + self.assertIn("MyLoop", error_message) # Loop name + self.assertIn("bad.value", error_message) # Invalid value + self.assertIn("alphanumeric", error_message) # Guidance about valid characters + self.assertIn("hyphens", error_message) # Guidance about valid characters + self.assertIn("underscores", error_message) # Guidance about valid characters + + def test_validate_mapping_key_compatibility_empty_collection(self): + """Test that empty collection passes validation (no values to validate)""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + ) + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=[], # Empty collection + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + +class TestInvalidMappingKeyError(TestCase): + """Test cases for InvalidMappingKeyError exception""" + + def test_invalid_mapping_key_error_single_value(self): + """Test InvalidMappingKeyError with a single invalid value""" + from samcli.commands.package.exceptions import InvalidMappingKeyError + + error = InvalidMappingKeyError( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + invalid_values=["bad.value"], + ) + + error_message = str(error) + self.assertIn("Services", error_message) + self.assertIn('"bad.value"', error_message) + self.assertIn("alphanumeric", error_message) + + def test_invalid_mapping_key_error_multiple_values(self): + """Test InvalidMappingKeyError with multiple invalid values""" + from samcli.commands.package.exceptions import InvalidMappingKeyError + + error = InvalidMappingKeyError( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + invalid_values=["bad.value", "another/bad", "third@invalid"], + ) + + error_message = str(error) + self.assertIn("Services", error_message) + self.assertIn('"bad.value"', error_message) + self.assertIn('"another/bad"', error_message) + self.assertIn('"third@invalid"', error_message) + + def test_invalid_mapping_key_error_attributes(self): + """Test that InvalidMappingKeyError stores attributes correctly""" + from samcli.commands.package.exceptions import InvalidMappingKeyError + + error = InvalidMappingKeyError( + foreach_key="Fn::ForEach::MyLoop", + loop_name="MyLoop", + invalid_values=["val1", "val2"], + ) + + self.assertEqual(error.foreach_key, "Fn::ForEach::MyLoop") + self.assertEqual(error.loop_name, "MyLoop") + self.assertEqual(error.invalid_values, ["val1", "val2"]) + + +class TestMappingsTransformation(TestCase): + """ + Comprehensive unit tests for Mappings transformation functionality. + + **Validates: Requirements 4.2, 4.3, 4.4, 4.5, 4.6, 4.8, 4.10** + + These tests verify: + 1. Mappings section generation with correct naming convention (4.6) + 2. Fn::FindInMap replacement of dynamic artifact property (4.4) + 3. Fn::ForEach structure preserved after transformation (4.5) + 4. Content-based S3 hash keys are unique per artifact (4.2) + 5. Warning emitted for parameter-based collections (4.8) + 6. Error for invalid Mapping key characters in collection values (4.10) + """ + + def _create_package_context(self, parameter_overrides=None): + """Helper to create a PackageContext instance for testing.""" + return PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + parameter_overrides=parameter_overrides, + ) + + # ========================================================================= + # Tests for Mappings section generation with correct naming convention + # Validates: Requirement 4.6 + # ========================================================================= + + def test_mappings_naming_convention_codeuri_services(self): + """Test Mappings naming convention: SAM{PropertyName}{LoopName} for CodeUri/Services""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + exported_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users.zip"}, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders.zip"}, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify naming convention: SAMCodeUriServices + self.assertIn("SAMCodeUriServices", mappings) + self.assertEqual(len(mappings), 1) + + def test_mappings_naming_convention_contenturi_layers(self): + """Test Mappings naming convention: SAM{PropertyName}{LoopName} for ContentUri/Layers""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Layers", + loop_name="Layers", + loop_variable="LayerName", + collection=["Common", "Utils"], + resource_key="${LayerName}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${LayerName}", + ) + ] + + exported_resources = { + "CommonLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "s3://bucket/common.zip"}, + }, + "UtilsLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "s3://bucket/utils.zip"}, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify naming convention: SAMContentUriLayers + self.assertIn("SAMContentUriLayers", mappings) + self.assertEqual(len(mappings), 1) + + def test_mappings_naming_convention_definitionuri_apis(self): + """Test Mappings naming convention: SAM{PropertyName}{LoopName} for DefinitionUri/APIs""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::APIs", + loop_name="APIs", + loop_variable="ApiName", + collection=["Users", "Orders"], + resource_key="${ApiName}Api", + resource_type="AWS::Serverless::Api", + property_name="DefinitionUri", + property_value="./api/${ApiName}/openapi.yaml", + ) + ] + + exported_resources = { + "UsersApi": { + "Type": "AWS::Serverless::Api", + "Properties": {"DefinitionUri": "s3://bucket/users-api.yaml"}, + }, + "OrdersApi": { + "Type": "AWS::Serverless::Api", + "Properties": {"DefinitionUri": "s3://bucket/orders-api.yaml"}, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify naming convention: SAMDefinitionUriAPIs + self.assertIn("SAMDefinitionUriAPIs", mappings) + self.assertEqual(len(mappings), 1) + + def test_mappings_naming_convention_multiple_properties(self): + """Test that multiple dynamic properties generate separate Mappings with correct names""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./functions/${Name}", + ), + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${Name}", + ), + ] + + exported_resources = { + "UsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users-func.zip"}, + }, + "OrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders-func.zip"}, + }, + "UsersLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "s3://bucket/users-layer.zip"}, + }, + "OrdersLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": {"ContentUri": "s3://bucket/orders-layer.zip"}, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify both Mappings are created with correct names + self.assertIn("SAMCodeUriServices", mappings) + self.assertIn("SAMContentUriServices", mappings) + self.assertEqual(len(mappings), 2) + + # ========================================================================= + # Tests for Fn::FindInMap replacement of dynamic artifact property + # Validates: Requirement 4.4 + # ========================================================================= + + def test_findmap_replacement_correct_structure(self): + """Test that Fn::FindInMap replacement has correct structure""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + resources = { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + result = package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + self.assertTrue(result) + + # Verify Fn::FindInMap structure + body = resources["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + + # Verify it's a dict with Fn::FindInMap key + self.assertIsInstance(code_uri, dict) + self.assertIn("Fn::FindInMap", code_uri) + + # Verify Fn::FindInMap has exactly 3 elements + findmap_args = code_uri["Fn::FindInMap"] + self.assertEqual(len(findmap_args), 3) + + # Verify the structure: [MappingName, LoopVariable, PropertyName] + self.assertEqual(findmap_args[0], "SAMCodeUriServices") # Mapping name + self.assertEqual(findmap_args[1], {"Ref": "Name"}) # Loop variable reference + self.assertEqual(findmap_args[2], "CodeUri") # Property name + + def test_findmap_replacement_uses_loop_variable_reference(self): + """Test that Fn::FindInMap uses the loop variable as second-level key""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + resources = { + "Fn::ForEach::Functions": [ + "FuncName", # Different loop variable name + ["Alpha", "Beta"], + { + "${FuncName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./${FuncName}", + "Handler": "index.handler", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Functions", + loop_name="Functions", + loop_variable="FuncName", + collection=["Alpha", "Beta"], + resource_key="${FuncName}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${FuncName}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + body = resources["Fn::ForEach::Functions"][2] + code_uri = body["${FuncName}Function"]["Properties"]["CodeUri"] + + # Verify the loop variable reference uses the correct variable name + self.assertEqual(code_uri["Fn::FindInMap"][1], {"Ref": "FuncName"}) + + # ========================================================================= + # Tests for Fn::ForEach structure preserved after transformation + # Validates: Requirement 4.5 + # ========================================================================= + + def test_foreach_structure_preserved_loop_variable(self): + """Test that loop variable is preserved after transformation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + resources = { + "Fn::ForEach::Services": [ + "ServiceName", # Original loop variable + ["Users", "Orders"], + { + "${ServiceName}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${ServiceName}", + "Handler": "index.handler", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="ServiceName", + collection=["Users", "Orders"], + resource_key="${ServiceName}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${ServiceName}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + # Verify loop variable is preserved + self.assertEqual(resources["Fn::ForEach::Services"][0], "ServiceName") + + def test_foreach_structure_preserved_collection(self): + """Test that collection is preserved after transformation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + original_collection = ["Users", "Orders", "Products"] + resources = { + "Fn::ForEach::Services": [ + "Name", + original_collection.copy(), + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=original_collection, + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + # Verify collection is preserved + self.assertEqual(resources["Fn::ForEach::Services"][1], original_collection) + + def test_foreach_structure_preserved_other_properties(self): + """Test that other properties in the resource are preserved after transformation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + resources = { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "${Name}.handler", # Dynamic handler + "Runtime": "python3.9", # Static property + "MemorySize": 256, # Static property + "Environment": { + "Variables": { + "SERVICE_NAME": "${Name}", # Dynamic env var + } + }, + }, + } + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + body = resources["Fn::ForEach::Services"][2] + props = body["${Name}Service"]["Properties"] + + # Verify other properties are preserved + self.assertEqual(props["Handler"], "${Name}.handler") + self.assertEqual(props["Runtime"], "python3.9") + self.assertEqual(props["MemorySize"], 256) + self.assertEqual(props["Environment"]["Variables"]["SERVICE_NAME"], "${Name}") + + def test_foreach_structure_preserved_multiple_resources(self): + """Test that multiple resources in ForEach body are preserved""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + resources = { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./functions/${Name}", + "Handler": "index.handler", + }, + }, + "${Name}Table": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "TableName": "${Name}Table", + }, + }, + }, + ] + } + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./functions/${Name}", + ) + + package_context._replace_dynamic_artifact_with_findmap(resources, prop) + + body = resources["Fn::ForEach::Services"][2] + + # Verify both resources are preserved + self.assertIn("${Name}Function", body) + self.assertIn("${Name}Table", body) + + # Verify DynamoDB table is unchanged + self.assertEqual(body["${Name}Table"]["Type"], "AWS::DynamoDB::Table") + self.assertEqual(body["${Name}Table"]["Properties"]["TableName"], "${Name}Table") + + # ========================================================================= + # Tests for content-based S3 hash keys are unique per artifact + # Validates: Requirement 4.2 + # ========================================================================= + + def test_s3_hash_keys_unique_per_artifact(self): + """Test that each artifact gets a unique S3 URI based on content hash""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders", "Products"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + # Simulate exported resources with unique content-based hashes + exported_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/abc123def456.zip", # Unique hash + }, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/789ghi012jkl.zip", # Different unique hash + }, + }, + "ProductsService": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "s3://bucket/mno345pqr678.zip", # Another unique hash + }, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify each collection value has a unique S3 URI + users_uri = mappings["SAMCodeUriServices"]["Users"]["CodeUri"] + orders_uri = mappings["SAMCodeUriServices"]["Orders"]["CodeUri"] + products_uri = mappings["SAMCodeUriServices"]["Products"]["CodeUri"] + + # All URIs should be different (unique content-based hashes) + self.assertNotEqual(users_uri, orders_uri) + self.assertNotEqual(users_uri, products_uri) + self.assertNotEqual(orders_uri, products_uri) + + # Verify the URIs match the exported resources + self.assertEqual(users_uri, "s3://bucket/abc123def456.zip") + self.assertEqual(orders_uri, "s3://bucket/789ghi012jkl.zip") + self.assertEqual(products_uri, "s3://bucket/mno345pqr678.zip") + + def test_s3_hash_keys_preserved_in_mappings(self): + """Test that S3 URIs with content hashes are correctly stored in Mappings""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Layers", + loop_name="Layers", + loop_variable="LayerName", + collection=["Common", "Utils"], + resource_key="${LayerName}Layer", + resource_type="AWS::Serverless::LayerVersion", + property_name="ContentUri", + property_value="./layers/${LayerName}", + ) + ] + + # Simulate exported resources with content-based hashes + exported_resources = { + "CommonLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "s3://my-bucket/prefix/common-layer-a1b2c3d4e5f6.zip", + }, + }, + "UtilsLayer": { + "Type": "AWS::Serverless::LayerVersion", + "Properties": { + "ContentUri": "s3://my-bucket/prefix/utils-layer-g7h8i9j0k1l2.zip", + }, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify the full S3 URIs with hashes are preserved + self.assertEqual( + mappings["SAMContentUriLayers"]["Common"]["ContentUri"], + "s3://my-bucket/prefix/common-layer-a1b2c3d4e5f6.zip", + ) + self.assertEqual( + mappings["SAMContentUriLayers"]["Utils"]["ContentUri"], "s3://my-bucket/prefix/utils-layer-g7h8i9j0k1l2.zip" + ) + + # ========================================================================= + # Tests for warning emitted for parameter-based collections + # Validates: Requirement 4.8 + # ========================================================================= + + @patch("samcli.commands.package.package_context.click") + def test_warning_emitted_for_parameter_based_collection_with_dynamic_codeuri(self, mock_click): + """Test that warning is emitted when using parameter-based collection with dynamic CodeUri""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=True, + collection_parameter_name="ServiceNames", + ) + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify warning was emitted + mock_click.secho.assert_called_once() + warning_msg = mock_click.secho.call_args[0][0] + + # Verify warning message contains key information + self.assertIn("Services", warning_msg) + self.assertIn("CodeUri", warning_msg) + self.assertIn("ServiceNames", warning_msg) + self.assertIn("fixed at package time", warning_msg) + self.assertIn("re-package", warning_msg) + + @patch("samcli.commands.package.package_context.click") + def test_no_warning_for_static_list_collection(self, mock_click): + """Test that no warning is emitted for static list collections""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + collection_is_parameter_ref=False, # Static list + collection_parameter_name=None, + ) + ] + + package_context._warn_parameter_based_collections(dynamic_properties) + + # Verify no warning was emitted + mock_click.secho.assert_not_called() + + # ========================================================================= + # Tests for error for invalid Mapping key characters in collection values + # Validates: Requirement 4.10 + # ========================================================================= + + def test_error_for_invalid_mapping_key_with_dots(self): + """Test that error is raised for collection values with dots""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = self._create_package_context() + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user.service", "order.api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError) as context: + package_context._validate_mapping_key_compatibility(prop) + + error_msg = str(context.exception) + self.assertIn("user.service", error_msg) + self.assertIn("order.api", error_msg) + self.assertIn("Services", error_msg) + + def test_error_for_invalid_mapping_key_with_slashes(self): + """Test that error is raised for collection values with slashes""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + from samcli.commands.package.exceptions import InvalidMappingKeyError + + package_context = self._create_package_context() + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user/service", "order/api"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + with self.assertRaises(InvalidMappingKeyError): + package_context._validate_mapping_key_compatibility(prop) + + def test_valid_mapping_keys_pass_validation(self): + """Test that valid collection values pass validation""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["user-service", "order_api", "Product123"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + + # Should not raise any exception + package_context._validate_mapping_key_compatibility(prop) + + # ========================================================================= + # Integration tests for complete Mappings transformation flow + # ========================================================================= + + def test_full_mappings_transformation_flow(self): + """Test the complete flow: detect -> generate mappings -> apply to template""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + # Original template with dynamic CodeUri + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + "Handler": "index.handler", + "Runtime": "python3.9", + }, + } + }, + ] + }, + } + + # Step 1: Detect dynamic properties + dynamic_properties = detect_dynamic_artifact_properties(template, package_context.parameter_overrides) + self.assertEqual(len(dynamic_properties), 1) + + # Step 2: Simulate exported resources + exported_resources = { + "UsersService": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users-hash123.zip"}, + }, + "OrdersService": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders-hash456.zip"}, + }, + } + + # Step 3: Generate Mappings + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Step 4: Apply Mappings to template + result = package_context._apply_artifact_mappings_to_template(template, mappings, dynamic_properties) + + # Verify Mappings section was added + self.assertIn("Mappings", result) + self.assertIn("SAMCodeUriServices", result["Mappings"]) + + # Verify Mappings content + self.assertEqual(result["Mappings"]["SAMCodeUriServices"]["Users"]["CodeUri"], "s3://bucket/users-hash123.zip") + self.assertEqual( + result["Mappings"]["SAMCodeUriServices"]["Orders"]["CodeUri"], "s3://bucket/orders-hash456.zip" + ) + + # Verify Fn::ForEach structure is preserved + self.assertIn("Fn::ForEach::Services", result["Resources"]) + + # Verify CodeUri was replaced with Fn::FindInMap + body = result["Resources"]["Fn::ForEach::Services"][2] + code_uri = body["${Name}Service"]["Properties"]["CodeUri"] + self.assertEqual(code_uri, {"Fn::FindInMap": ["SAMCodeUriServices", {"Ref": "Name"}, "CodeUri"]}) + + # Verify other properties are preserved + self.assertEqual(body["${Name}Service"]["Properties"]["Handler"], "index.handler") + self.assertEqual(body["${Name}Service"]["Properties"]["Runtime"], "python3.9") + + def test_mappings_transformation_with_existing_mappings(self): + """Test that generated Mappings are merged with existing Mappings""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + # Template with existing Mappings + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Mappings": { + "ExistingMapping": { + "Key1": {"Value": "existing-value-1"}, + "Key2": {"Value": "existing-value-2"}, + } + }, + "Resources": { + "Fn::ForEach::Services": [ + "Name", + ["Users", "Orders"], + { + "${Name}Service": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "./services/${Name}", + }, + } + }, + ] + }, + } + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Name", + collection=["Users", "Orders"], + resource_key="${Name}Service", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Name}", + ) + ] + + mappings = { + "SAMCodeUriServices": { + "Users": {"CodeUri": "s3://bucket/users.zip"}, + "Orders": {"CodeUri": "s3://bucket/orders.zip"}, + } + } + + result = package_context._apply_artifact_mappings_to_template(template, mappings, dynamic_properties) + + # Verify both existing and new Mappings are present + self.assertIn("ExistingMapping", result["Mappings"]) + self.assertIn("SAMCodeUriServices", result["Mappings"]) + + # Verify existing Mappings are unchanged + self.assertEqual(result["Mappings"]["ExistingMapping"]["Key1"]["Value"], "existing-value-1") + + def test_mappings_transformation_with_lambda_function_code(self): + """Test Mappings transformation for AWS::Lambda::Function Code property""" + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Functions", + loop_name="Functions", + loop_variable="Name", + collection=["Alpha", "Beta"], + resource_key="${Name}Function", + resource_type="AWS::Lambda::Function", + property_name="Code", + property_value="./functions/${Name}", + ) + ] + + # Lambda Function Code property can be a dict with S3Bucket/S3Key + exported_resources = { + "AlphaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": {"S3Bucket": "my-bucket", "S3Key": "alpha-code.zip"}, + }, + }, + "BetaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": {"S3Bucket": "my-bucket", "S3Key": "beta-code.zip"}, + }, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + # Verify S3 URIs are constructed correctly from S3Bucket/S3Key + self.assertEqual(mappings["SAMCodeFunctions"]["Alpha"]["Code"], "s3://my-bucket/alpha-code.zip") + self.assertEqual(mappings["SAMCodeFunctions"]["Beta"]["Code"], "s3://my-bucket/beta-code.zip") + + +class TestBuildToPackageFlow(TestCase): + """ + Tests verifying that sam package correctly handles build output templates + that already contain Mappings from sam build's dynamic artifact handling. + + When sam build processes a template with dynamic CodeUri (e.g., CodeUri: ${Name}/), + it generates Mappings with build artifact paths and replaces CodeUri with Fn::FindInMap. + sam package must detect this Fn::FindInMap as dynamic and regenerate Mappings with S3 URIs. + + Validates Requirements: 6.6, 12.1 + """ + + def _create_package_context(self, parameter_overrides=None): + return PackageContext( + template_file="template-file", + s3_bucket="s3-bucket", + s3_prefix="s3-prefix", + image_repository=None, + image_repositories=None, + kms_key_id=None, + output_template_file=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata={}, + region=None, + profile=None, + parameter_overrides=parameter_overrides, + ) + + def test_findmap_codeuri_detected_as_dynamic(self): + """ + Verify that Fn::FindInMap containing loop variable is detected as dynamic. + + After sam build, CodeUri becomes: + Fn::FindInMap: [SAMCodeUriFunctions, {Ref: FunctionName}, CodeUri] + + contains_loop_variable should detect {"Ref": "FunctionName"} in the list. + """ + findmap_value = {"Fn::FindInMap": ["SAMCodeUriFunctions", {"Ref": "FunctionName"}, "CodeUri"]} + + self.assertTrue( + contains_loop_variable(findmap_value, "FunctionName"), + "Fn::FindInMap with {Ref: FunctionName} should be detected as containing loop variable", + ) + + def test_findmap_codeuri_without_loop_variable_not_detected(self): + """Verify Fn::FindInMap without loop variable is not detected as dynamic.""" + findmap_value = {"Fn::FindInMap": ["SomeMapping", "StaticKey", "CodeUri"]} + + self.assertFalse( + contains_loop_variable(findmap_value, "FunctionName"), + "Fn::FindInMap without loop variable should not be detected as dynamic", + ) + + def test_detect_dynamic_properties_from_build_output(self): + """ + Verify _detect_dynamic_artifact_properties correctly detects Fn::FindInMap + as dynamic when processing a build output template. + + This simulates the template that sam build produces: + - Mappings section with SAMCodeUriFunctions containing build paths + - CodeUri replaced with Fn::FindInMap + """ + package_context = self._create_package_context() + + # Template as produced by sam build (with Mappings and Fn::FindInMap) + build_output_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Mappings": { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": "AlphaFunction/"}, + "Beta": {"CodeUri": "BetaFunction/"}, + } + }, + "Resources": { + "Fn::ForEach::Functions": [ + "FunctionName", + ["Alpha", "Beta"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "main.handler", + "CodeUri": { + "Fn::FindInMap": [ + "SAMCodeUriFunctions", + {"Ref": "FunctionName"}, + "CodeUri", + ] + }, + }, + } + }, + ] + }, + } + + dynamic_properties = detect_dynamic_artifact_properties( + build_output_template, package_context.parameter_overrides + ) + + self.assertEqual(len(dynamic_properties), 1, "Should detect one dynamic artifact property") + + prop = dynamic_properties[0] + self.assertEqual(prop.foreach_key, "Fn::ForEach::Functions") + self.assertEqual(prop.loop_variable, "FunctionName") + self.assertEqual(prop.property_name, "CodeUri") + self.assertEqual(prop.collection, ["Alpha", "Beta"]) + + def test_generate_mappings_overwrites_build_mappings_with_s3_uris(self): + """ + Verify that _generate_artifact_mappings produces S3 URIs that will + overwrite the build-time Mappings when applied to the template. + """ + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Functions", + loop_name="Functions", + loop_variable="FunctionName", + collection=["Alpha", "Beta"], + resource_key="${FunctionName}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value={"Fn::FindInMap": ["SAMCodeUriFunctions", {"Ref": "FunctionName"}, "CodeUri"]}, + ) + ] + + exported_resources = { + "AlphaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/alpha-abc123.zip"}, + }, + "BetaFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/beta-def456.zip"}, + }, + } + + mappings, _ = package_context._generate_artifact_mappings(dynamic_properties, "/tmp", exported_resources) + + self.assertIn("SAMCodeUriFunctions", mappings) + self.assertEqual( + mappings["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], + "s3://bucket/alpha-abc123.zip", + ) + self.assertEqual( + mappings["SAMCodeUriFunctions"]["Beta"]["CodeUri"], + "s3://bucket/beta-def456.zip", + ) + + def test_apply_mappings_replaces_build_mappings(self): + """ + Verify that _apply_artifact_mappings_to_template correctly replaces + build-time Mappings with S3 URI Mappings in the output template. + """ + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + package_context = self._create_package_context() + + # Template with build-time Mappings + template = { + "Mappings": { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": "AlphaFunction/"}, + "Beta": {"CodeUri": "BetaFunction/"}, + } + }, + "Resources": { + "Fn::ForEach::Functions": [ + "FunctionName", + ["Alpha", "Beta"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "main.handler", + "CodeUri": { + "Fn::FindInMap": [ + "SAMCodeUriFunctions", + {"Ref": "FunctionName"}, + "CodeUri", + ] + }, + }, + } + }, + ] + }, + } + + # New Mappings with S3 URIs + new_mappings = { + "SAMCodeUriFunctions": { + "Alpha": {"CodeUri": "s3://bucket/alpha-abc123.zip"}, + "Beta": {"CodeUri": "s3://bucket/beta-def456.zip"}, + } + } + + dynamic_properties = [ + DynamicArtifactProperty( + foreach_key="Fn::ForEach::Functions", + loop_name="Functions", + loop_variable="FunctionName", + collection=["Alpha", "Beta"], + resource_key="${FunctionName}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value={"Fn::FindInMap": ["SAMCodeUriFunctions", {"Ref": "FunctionName"}, "CodeUri"]}, + ) + ] + + result = package_context._apply_artifact_mappings_to_template(template, new_mappings, dynamic_properties) + + # Verify Mappings were replaced with S3 URIs + result_mappings = result.get("Mappings", {}) + self.assertEqual( + result_mappings["SAMCodeUriFunctions"]["Alpha"]["CodeUri"], + "s3://bucket/alpha-abc123.zip", + ) + self.assertEqual( + result_mappings["SAMCodeUriFunctions"]["Beta"]["CodeUri"], + "s3://bucket/beta-def456.zip", + ) + + # Verify Fn::FindInMap is preserved in the ForEach body + foreach_block = result["Resources"]["Fn::ForEach::Functions"] + body = foreach_block[2] + codeuri = body["${FunctionName}Function"]["Properties"]["CodeUri"] + self.assertIn("Fn::FindInMap", codeuri) + self.assertEqual(codeuri["Fn::FindInMap"][0], "SAMCodeUriFunctions") diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py new file mode 100644 index 00000000000..287af3ca9fb --- /dev/null +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -0,0 +1,823 @@ +""" +Tests for PackageContext language extensions support. + +Covers _copy_artifact_uris_for_type with various resource types and dynamic property skipping. +""" + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from samcli.commands.package.package_context import PackageContext + + +class TestCopyArtifactUrisForType(TestCase): + """Tests for _copy_artifact_uris_for_type.""" + + def _make_context(self): + """Create a minimal PackageContext for testing.""" + with patch.object(PackageContext, "__init__", lambda self: None): + ctx = PackageContext() + return ctx + + def test_serverless_function_codeuri(self): + ctx = self._make_context() + original = {} + exported = {"CodeUri": "s3://bucket/code.zip"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::Function") + self.assertTrue(result) + self.assertEqual(original["CodeUri"], "s3://bucket/code.zip") + + def test_serverless_function_imageuri(self): + ctx = self._make_context() + original = {} + exported = {"ImageUri": "123456.dkr.ecr.us-east-1.amazonaws.com/repo:tag"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::Function") + self.assertTrue(result) + self.assertEqual(original["ImageUri"], "123456.dkr.ecr.us-east-1.amazonaws.com/repo:tag") + + def test_lambda_function_code(self): + ctx = self._make_context() + original = {} + exported = {"Code": {"S3Bucket": "bucket", "S3Key": "key"}} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Lambda::Function") + self.assertTrue(result) + self.assertEqual(original["Code"]["S3Bucket"], "bucket") + + def test_serverless_layer_contenturi(self): + ctx = self._make_context() + original = {} + exported = {"ContentUri": "s3://bucket/layer.zip"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::LayerVersion") + self.assertTrue(result) + self.assertEqual(original["ContentUri"], "s3://bucket/layer.zip") + + def test_lambda_layer_content(self): + ctx = self._make_context() + original = {} + exported = {"Content": {"S3Bucket": "bucket", "S3Key": "key"}} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Lambda::LayerVersion") + self.assertTrue(result) + + def test_serverless_api_definitionuri(self): + ctx = self._make_context() + original = {} + exported = {"DefinitionUri": "s3://bucket/api.yaml"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::Api") + self.assertTrue(result) + self.assertEqual(original["DefinitionUri"], "s3://bucket/api.yaml") + + def test_serverless_httpapi_definitionuri(self): + ctx = self._make_context() + original = {} + exported = {"DefinitionUri": "s3://bucket/httpapi.yaml"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::HttpApi") + self.assertTrue(result) + + def test_serverless_statemachine_definitionuri(self): + ctx = self._make_context() + original = {} + exported = {"DefinitionUri": "s3://bucket/sm.json"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::StateMachine") + self.assertTrue(result) + + def test_serverless_graphqlapi_schemauri(self): + ctx = self._make_context() + original = {} + exported = {"SchemaUri": "s3://bucket/schema.graphql"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::GraphQLApi") + self.assertTrue(result) + self.assertEqual(original["SchemaUri"], "s3://bucket/schema.graphql") + + def test_serverless_graphqlapi_codeuri(self): + ctx = self._make_context() + original = {} + exported = {"CodeUri": "s3://bucket/resolvers.zip"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::GraphQLApi") + self.assertTrue(result) + + def test_apigateway_restapi_bodys3location(self): + ctx = self._make_context() + original = {} + exported = {"BodyS3Location": "s3://bucket/body.yaml"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::ApiGateway::RestApi") + self.assertTrue(result) + + def test_apigatewayv2_api_bodys3location(self): + ctx = self._make_context() + original = {} + exported = {"BodyS3Location": "s3://bucket/body.yaml"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::ApiGatewayV2::Api") + self.assertTrue(result) + + def test_stepfunctions_statemachine_definitions3location(self): + ctx = self._make_context() + original = {} + exported = {"DefinitionS3Location": "s3://bucket/sm.json"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::StepFunctions::StateMachine") + self.assertTrue(result) + + def test_unknown_resource_type_returns_false(self): + ctx = self._make_context() + original = {} + exported = {"SomeUri": "s3://bucket/thing"} + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::SNS::Topic") + self.assertFalse(result) + + def test_no_matching_property_returns_false(self): + ctx = self._make_context() + original = {} + exported = {"Handler": "index.handler"} # Not an artifact property + result = ctx._copy_artifact_uris_for_type(original, exported, "AWS::Serverless::Function") + self.assertFalse(result) + + def test_dynamic_property_skipped(self): + ctx = self._make_context() + original = {} + exported = {"CodeUri": "s3://bucket/code.zip"} + dynamic_keys = {("Fn::ForEach::Funcs", "CodeUri")} + result = ctx._copy_artifact_uris_for_type( + original, + exported, + "AWS::Serverless::Function", + foreach_key="Fn::ForEach::Funcs", + dynamic_prop_keys=dynamic_keys, + ) + self.assertFalse(result) + self.assertNotIn("CodeUri", original) + + def test_non_dynamic_property_not_skipped(self): + ctx = self._make_context() + original = {} + exported = {"CodeUri": "s3://bucket/code.zip"} + dynamic_keys = {("Fn::ForEach::Other", "CodeUri")} + result = ctx._copy_artifact_uris_for_type( + original, + exported, + "AWS::Serverless::Function", + foreach_key="Fn::ForEach::Funcs", + dynamic_prop_keys=dynamic_keys, + ) + self.assertTrue(result) + self.assertEqual(original["CodeUri"], "s3://bucket/code.zip") + + def test_no_foreach_key_skips_dynamic_check(self): + ctx = self._make_context() + original = {} + exported = {"CodeUri": "s3://bucket/code.zip"} + dynamic_keys = {("Fn::ForEach::Funcs", "CodeUri")} + result = ctx._copy_artifact_uris_for_type( + original, + exported, + "AWS::Serverless::Function", + foreach_key=None, + dynamic_prop_keys=dynamic_keys, + ) + self.assertTrue(result) + + +class TestDetectForeachDynamicProperties(TestCase): + """Tests for detect_foreach_dynamic_properties in sam_integration module.""" + + def test_non_string_loop_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties("Fn::ForEach::X", [123, ["A"], {}], {}) + self.assertEqual(result, []) + + def test_non_dict_output_template(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"], "not a dict"], {}) + self.assertEqual(result, []) + + def test_non_dict_resource_def_skipped(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties("Fn::ForEach::X", ["Name", ["A"], {"${Name}Func": "not a dict"}], {}) + self.assertEqual(result, []) + + def test_non_string_resource_type_skipped(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + ["Name", ["A"], {"${Name}Func": {"Type": 123, "Properties": {}}}], + {}, + ) + self.assertEqual(result, []) + + def test_non_packageable_resource_skipped(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + ["Name", ["A"], {"${Name}Topic": {"Type": "AWS::SNS::Topic", "Properties": {}}}], + {}, + ) + self.assertEqual(result, []) + + def test_non_dict_properties_skipped(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + ["Name", ["A"], {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": "bad"}}], + {}, + ) + self.assertEqual(result, []) + + def test_parameter_ref_collection_detected(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + template = { + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "A,B"}}, + } + result = detect_foreach_dynamic_properties( + "Fn::ForEach::Funcs", + [ + "Name", + {"Ref": "Names"}, + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./${Name}"}}}, + ], + template, + ) + self.assertEqual(len(result), 1) + self.assertTrue(result[0].collection_is_parameter_ref) + self.assertEqual(result[0].collection_parameter_name, "Names") + + def test_static_collection_not_parameter_ref(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties( + "Fn::ForEach::Funcs", + [ + "Name", + ["A", "B"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./${Name}"}}}, + ], + {}, + ) + self.assertEqual(len(result), 1) + self.assertFalse(result[0].collection_is_parameter_ref) + + def test_empty_collection_returns_empty(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_foreach_dynamic_properties + + result = detect_foreach_dynamic_properties( + "Fn::ForEach::X", + [ + "Name", + {"Ref": "Missing"}, + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./${Name}"}}}, + ], + {}, + ) + self.assertEqual(result, []) + + +class TestResolveCollection(TestCase): + """Tests for resolve_collection in sam_integration module.""" + + def test_static_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection + + result = resolve_collection(["A", "B", "C"], {}) + self.assertEqual(result, ["A", "B", "C"]) + + def test_static_list_with_none(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection + + result = resolve_collection(["A", None, "C"], {}) + self.assertEqual(result, ["A", "C"]) + + def test_ref_parameter(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection + + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "X,Y"}}} + result = resolve_collection({"Ref": "Names"}, template) + self.assertEqual(result, ["X", "Y"]) + + def test_unsupported_returns_empty(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection + + result = resolve_collection("string", {}) + self.assertEqual(result, []) + + def test_non_ref_dict_returns_empty(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection + + result = resolve_collection({"Fn::Split": [",", "a,b"]}, {}) + self.assertEqual(result, []) + + +class TestResolveParameterCollection(TestCase): + """Tests for resolve_parameter_collection in sam_integration module.""" + + def test_from_overrides_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + result = resolve_parameter_collection("Names", {}, parameter_values={"Names": ["A", "B"]}) + self.assertEqual(result, ["A", "B"]) + + def test_from_overrides_comma_string(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + result = resolve_parameter_collection("Names", {}, parameter_values={"Names": "X, Y, Z"}) + self.assertEqual(result, ["X", "Y", "Z"]) + + def test_from_template_default_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": ["P", "Q"]}}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, ["P", "Q"]) + + def test_from_template_default_string(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "M,N"}}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, ["M", "N"]) + + def test_not_found_returns_empty(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + result = resolve_parameter_collection("Missing", {}) + self.assertEqual(result, []) + + def test_non_dict_param_def(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + template = {"Parameters": {"Names": "not a dict"}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, []) + + def test_no_default_returns_empty(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList"}}} + result = resolve_parameter_collection("Names", template) + self.assertEqual(result, []) + + def test_overrides_take_precedence(self): + from samcli.lib.cfn_language_extensions.sam_integration import resolve_parameter_collection + + template = {"Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Default1,Default2"}}} + result = resolve_parameter_collection("Names", template, parameter_values={"Names": "Override1,Override2"}) + self.assertEqual(result, ["Override1", "Override2"]) + + +class TestReplaceDynamicArtifactEdgeCases(TestCase): + """Tests for _replace_dynamic_artifact_with_findmap edge cases.""" + + def _make_context(self): + with patch.object(PackageContext, "__init__", lambda self: None): + ctx = PackageContext() + ctx.parameter_overrides = {} + return ctx + + def test_body_not_dict_returns_false(self): + from samcli.commands.package.package_context import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Funcs", + loop_name="Funcs", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + resources = { + "Fn::ForEach::Funcs": ["Name", ["A", "B"], "not a dict"], + } + result = ctx._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + def test_properties_not_dict_returns_false(self): + from samcli.commands.package.package_context import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Funcs", + loop_name="Funcs", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + resources = { + "Fn::ForEach::Funcs": [ + "Name", + ["A", "B"], + {"${Name}Func": {"Type": "AWS::Serverless::Function", "Properties": "not a dict"}}, + ], + } + result = ctx._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + def test_resource_key_not_found_returns_false(self): + from samcli.commands.package.package_context import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Funcs", + loop_name="Funcs", + loop_variable="Name", + collection=["A", "B"], + resource_key="${Name}Func", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./${Name}", + ) + resources = { + "Fn::ForEach::Funcs": [ + "Name", + ["A", "B"], + {"${Name}Other": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "./${Name}"}}}, + ], + } + result = ctx._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertFalse(result) + + +class TestContainsLoopVariablePackageContext(TestCase): + """Tests for contains_loop_variable in sam_integration module.""" + + def test_ref_dict_matches(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Ref": "Name"}, "Name")) + + def test_ref_dict_no_match(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertFalse(contains_loop_variable({"Ref": "Other"}, "Name")) + + def test_fn_sub_string(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Fn::Sub": "./${Name}/code"}, "Name")) + + def test_fn_sub_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Fn::Sub": ["./${Name}/code", {}]}, "Name")) + + def test_fn_sub_empty_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertFalse(contains_loop_variable({"Fn::Sub": []}, "Name")) + + def test_nested_dict(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable({"Fn::Join": ["/", ["${Name}"]]}, "Name")) + + def test_list_with_variable(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertTrue(contains_loop_variable(["${Name}", "other"], "Name")) + + def test_non_string_non_dict_non_list(self): + from samcli.lib.cfn_language_extensions.sam_integration import contains_loop_variable + + self.assertFalse(contains_loop_variable(42, "Name")) + + +class TestNestedForEachRecursiveDetection(TestCase): + """Tests for recursive detection of dynamic artifact properties in nested Fn::ForEach.""" + + def test_nested_foreach_inner_dynamic_detected(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + "Fn::ForEach::Envs": [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./services/${Svc}", "Handler": "index.handler"}, + } + }, + ] + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].loop_variable, "Svc") + self.assertEqual(result[0].loop_name, "Services") + self.assertEqual(result[0].collection, ["Users", "Orders"]) + self.assertEqual(result[0].foreach_key, "Fn::ForEach::Services") + # outer_loops should contain the enclosing Envs loop + self.assertEqual(len(result[0].outer_loops), 1) + self.assertEqual(result[0].outer_loops[0][0], "Fn::ForEach::Envs") + self.assertEqual(result[0].outer_loops[0][1], "Env") + self.assertEqual(result[0].outer_loops[0][2], ["dev", "prod"]) + + def test_non_nested_foreach_still_works(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./services/${Svc}"}, + } + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].outer_loops, []) + + def test_nested_foreach_static_not_detected(self): + from samcli.lib.cfn_language_extensions.sam_integration import detect_dynamic_artifact_properties + + template = { + "Resources": { + "Fn::ForEach::Envs": [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src", "Handler": "index.handler"}, + } + }, + ] + }, + ] + } + } + result = detect_dynamic_artifact_properties(template) + self.assertEqual(len(result), 0) + + +class TestNestedForEachPackageS3UriUpdate(TestCase): + """Tests for recursive _update_foreach_with_s3_uris in PackageContext.""" + + def _make_context(self): + with patch.object(PackageContext, "__init__", lambda self: None): + ctx = PackageContext() + return ctx + + def test_nested_foreach_recurses_into_inner_block(self): + ctx = self._make_context() + foreach_value = [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./src"}, + } + }, + ] + }, + ] + exported_resources = { + "devUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/abc.zip"}, + }, + "devOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/abc.zip"}, + }, + } + # Should not raise — recursion should handle the nested block + ctx._update_foreach_with_s3_uris("Fn::ForEach::Envs", foreach_value, exported_resources) + # The inner static CodeUri should be updated + inner_body = foreach_value[2]["Fn::ForEach::Services"][2] + inner_props = inner_body["${Env}${Svc}Function"]["Properties"] + self.assertEqual(inner_props["CodeUri"], "s3://bucket/abc.zip") + + def test_nested_foreach_skips_dynamic_properties(self): + ctx = self._make_context() + foreach_value = [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./services/${Svc}"}, + } + }, + ] + }, + ] + exported_resources = { + "devUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users.zip"}, + }, + } + dynamic_prop_keys = {("Fn::ForEach::Services", "CodeUri")} + ctx._update_foreach_with_s3_uris("Fn::ForEach::Envs", foreach_value, exported_resources, dynamic_prop_keys) + # Dynamic property should NOT be updated (handled by Mappings) + inner_body = foreach_value[2]["Fn::ForEach::Services"][2] + inner_props = inner_body["${Env}${Svc}Function"]["Properties"] + self.assertEqual(inner_props["CodeUri"], "./services/${Svc}") + + +class TestNestedForEachGenerateArtifactMappings(TestCase): + """Tests for _generate_artifact_mappings with nested ForEach (compound vs simple keys).""" + + def _make_context(self): + with patch.object(PackageContext, "__init__", lambda self: None): + ctx = PackageContext() + return ctx + + def test_inner_only_variable_produces_simple_keys(self): + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Svc", + collection=["Users", "Orders"], + resource_key="${Env}${Svc}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Svc}", + outer_loops=[("Fn::ForEach::Envs", "Env", ["dev", "prod"])], + ) + exported_resources = { + "devUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users.zip"}, + }, + "devOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders.zip"}, + }, + "prodUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users.zip"}, + }, + "prodOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders.zip"}, + }, + } + mappings, prop_to_mapping = ctx._generate_artifact_mappings([prop], "/tmp", exported_resources) + self.assertIn("SAMCodeUriServices", mappings) + # Simple keys — inner collection values only + self.assertIn("Users", mappings["SAMCodeUriServices"]) + self.assertIn("Orders", mappings["SAMCodeUriServices"]) + self.assertEqual(mappings["SAMCodeUriServices"]["Users"]["CodeUri"], "s3://bucket/users.zip") + + def test_compound_keys_when_outer_variable_referenced(self): + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Svc", + collection=["Users", "Orders"], + resource_key="${Env}${Svc}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Env}/${Svc}", # References BOTH variables + outer_loops=[("Fn::ForEach::Envs", "Env", ["dev", "prod"])], + ) + exported_resources = { + "devUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/dev-users.zip"}, + }, + "devOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/dev-orders.zip"}, + }, + "prodUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/prod-users.zip"}, + }, + "prodOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/prod-orders.zip"}, + }, + } + mappings, _ = ctx._generate_artifact_mappings([prop], "/tmp", exported_resources) + self.assertIn("SAMCodeUriServices", mappings) + # Compound keys + self.assertIn("dev-Users", mappings["SAMCodeUriServices"]) + self.assertIn("dev-Orders", mappings["SAMCodeUriServices"]) + self.assertIn("prod-Users", mappings["SAMCodeUriServices"]) + self.assertIn("prod-Orders", mappings["SAMCodeUriServices"]) + self.assertEqual(mappings["SAMCodeUriServices"]["dev-Users"]["CodeUri"], "s3://bucket/dev-users.zip") + + def test_non_nested_behavior_unchanged(self): + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + ctx = self._make_context() + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Svc", + collection=["Users", "Orders"], + resource_key="${Svc}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Svc}", + outer_loops=[], # No outer loops + ) + exported_resources = { + "UsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/users.zip"}, + }, + "OrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/orders.zip"}, + }, + } + mappings, _ = ctx._generate_artifact_mappings([prop], "/tmp", exported_resources) + self.assertIn("SAMCodeUriServices", mappings) + self.assertIn("Users", mappings["SAMCodeUriServices"]) + self.assertIn("Orders", mappings["SAMCodeUriServices"]) + # No compound keys + self.assertNotIn("dev-Users", mappings["SAMCodeUriServices"]) + + +class TestNestedForEachReplaceWithFindInMap(TestCase): + """Tests for _replace_dynamic_artifact_with_findmap with nested ForEach.""" + + def _make_context(self): + with patch.object(PackageContext, "__init__", lambda self: None): + ctx = PackageContext() + return ctx + + def test_nested_foreach_traverses_outer_loops(self): + from samcli.lib.samlib.wrapper import DynamicArtifactProperty + + ctx = self._make_context() + resources = { + "Fn::ForEach::Envs": [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Services": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "./services/${Svc}"}, + } + }, + ] + }, + ] + } + prop = DynamicArtifactProperty( + foreach_key="Fn::ForEach::Services", + loop_name="Services", + loop_variable="Svc", + collection=["Users", "Orders"], + resource_key="${Env}${Svc}Function", + resource_type="AWS::Serverless::Function", + property_name="CodeUri", + property_value="./services/${Svc}", + outer_loops=[("Fn::ForEach::Envs", "Env", ["dev", "prod"])], + ) + result = ctx._replace_dynamic_artifact_with_findmap(resources, prop) + self.assertTrue(result) + # Verify the inner property was replaced + inner_body = resources["Fn::ForEach::Envs"][2]["Fn::ForEach::Services"][2] + inner_props = inner_body["${Env}${Svc}Function"]["Properties"] + self.assertIn("Fn::FindInMap", inner_props["CodeUri"]) + self.assertEqual(inner_props["CodeUri"]["Fn::FindInMap"][0], "SAMCodeUriServices") diff --git a/tests/unit/lib/deploy/test_deployer.py b/tests/unit/lib/deploy/test_deployer.py index 154c4019bd9..17db1a478f8 100644 --- a/tests/unit/lib/deploy/test_deployer.py +++ b/tests/unit/lib/deploy/test_deployer.py @@ -15,6 +15,7 @@ DeployStackOutPutFailedError, DeployBucketInDifferentRegionError, DeployStackStatusMissingError, + MissingMappingKeyError, ) from samcli.lib.deploy.deployer import Deployer from samcli.lib.deploy.utils import FailureMode @@ -1480,3 +1481,61 @@ def test_show_stabilizing_status(self, patched_pprint_columns): ["CREATE_COMPLETE", "AWS::CloudFormation::Stack", "my-stack-name"], patched_pprint_columns.call_args_list[2][1]["columns"], ) + + +class TestCreateDeployError(TestCase): + """Tests for the _create_deploy_error static method""" + + def setUp(self): + self.session = MagicMock() + self.cloudformation_client = self.session.client("cloudformation") + self.deployer = Deployer(self.cloudformation_client) + + def test_create_deploy_error_returns_missing_mapping_key_error_for_findmap_error(self): + """Test that _create_deploy_error returns MissingMappingKeyError for FindInMap errors""" + from samcli.commands.deploy.exceptions import MissingMappingKeyError + + error_message = "Fn::FindInMap - Key 'Products' not found in Mapping 'SAMCodeUriServices'" + error = Deployer._create_deploy_error("test-stack", error_message) + + self.assertIsInstance(error, MissingMappingKeyError) + self.assertEqual(error.stack_name, "test-stack") + self.assertEqual(error.missing_key, "Products") + self.assertEqual(error.mapping_name, "SAMCodeUriServices") + + def test_create_deploy_error_returns_deploy_failed_error_for_other_errors(self): + """Test that _create_deploy_error returns DeployFailedError for non-FindInMap errors""" + error_message = "Some other CloudFormation error" + error = Deployer._create_deploy_error("test-stack", error_message) + + self.assertIsInstance(error, DeployFailedError) + self.assertEqual(error.stack_name, "test-stack") + self.assertIn(error_message, error.msg) + + def test_create_deploy_error_handles_waiter_error_format(self): + """Test that _create_deploy_error handles WaiterError format with FindInMap error""" + from samcli.commands.deploy.exceptions import MissingMappingKeyError + + error_message = ( + "Waiter StackCreateComplete failed: Waiter encountered a terminal failure state: " + 'For expression "Stacks[].StackStatus" we matched expected path: "CREATE_FAILED" ' + "at least once. Resource handler returned message: \"Fn::FindInMap - Key 'NewService' " + "not found in Mapping 'SAMCodeUriMyLoop'\" (RequestToken: abc123)" + ) + error = Deployer._create_deploy_error("my-stack", error_message) + + self.assertIsInstance(error, MissingMappingKeyError) + self.assertEqual(error.stack_name, "my-stack") + self.assertEqual(error.missing_key, "NewService") + self.assertEqual(error.mapping_name, "SAMCodeUriMyLoop") + + def test_create_deploy_error_preserves_original_error_message(self): + """Test that the original error message is preserved in MissingMappingKeyError""" + from samcli.commands.deploy.exceptions import MissingMappingKeyError + + error_message = "Fn::FindInMap - Key 'Alpha' not found in Mapping 'SAMCodeUriLoop'" + error = Deployer._create_deploy_error("test-stack", error_message) + + self.assertIsInstance(error, MissingMappingKeyError) + self.assertEqual(error.original_error, error_message) + self.assertIn(error_message, str(error)) From dae1ae982830fa47be9fd097b6e70eeeaf822446 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Fri, 13 Feb 2026 23:38:07 +0000 Subject: [PATCH 17/21] test: add validate, local invoke, and start-api integration tests - sam validate: valid ForEach, invalid syntax, cloud-dependent collections, dynamic CodeUri, nested depth validation (5 valid, 6 invalid) - sam local invoke: expanded function names from ForEach - sam local start-api: ForEach-generated API endpoints --- .../invoke/test_invoke_language_extensions.py | 247 ++++++++++++++ .../test_start_api_language_extensions.py | 96 ++++++ .../start_api/language-extensions-api.yaml | 42 +++ .../cloud-dependent-collection.yaml | 39 +++ .../language-extensions/invalid-foreach.yaml | 19 ++ .../missing-dynamic-artifact-dir.yaml | 27 ++ .../valid-dynamic-codeuri.yaml | 26 ++ .../language-extensions/valid-foreach.yaml | 34 ++ .../valid-tojsonstring.yaml | 56 ++++ .../validate/test_validate_command.py | 307 ++++++++++++++++++ 10 files changed, 893 insertions(+) create mode 100644 tests/integration/local/invoke/test_invoke_language_extensions.py create mode 100644 tests/integration/local/start_api/test_start_api_language_extensions.py create mode 100644 tests/integration/testdata/start_api/language-extensions-api.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/cloud-dependent-collection.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/invalid-foreach.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/missing-dynamic-artifact-dir.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/valid-dynamic-codeuri.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/valid-foreach.yaml create mode 100644 tests/integration/testdata/validate/language-extensions/valid-tojsonstring.yaml diff --git a/tests/integration/local/invoke/test_invoke_language_extensions.py b/tests/integration/local/invoke/test_invoke_language_extensions.py new file mode 100644 index 00000000000..d243e61849f --- /dev/null +++ b/tests/integration/local/invoke/test_invoke_language_extensions.py @@ -0,0 +1,247 @@ +""" +Integration tests for sam local invoke with CloudFormation Language Extensions. + +Tests verify that sam local invoke can invoke functions generated by Fn::ForEach +expansion from templates using AWS::LanguageExtensions transform. + +Requirements: +- 4.1: WHEN `sam local invoke` is called with an expanded function name (e.g., `AlphaFunction`), + THE Local_Invoke_Command SHALL invoke that specific function +- 12.2: THE Integration_Tests SHALL verify `sam local invoke` can invoke expanded functions + by their generated names +""" + +import os +import shutil +import sys +import tempfile +import uuid +from pathlib import Path + +import pytest + +from tests.integration.local.invoke.invoke_integ_base import InvokeIntegBase +from tests.testing_utils import get_sam_command, run_command + + +class TestInvokeLanguageExtensions(InvokeIntegBase): + """ + Integration tests for sam local invoke with CloudFormation Language Extensions. + + This test class verifies that functions generated by Fn::ForEach expansion + can be invoked using sam local invoke with their expanded names. + """ + + template_subdir = "buildcmd" + + def setUp(self): + """Set up test fixtures with a scratch directory for build artifacts.""" + # Use a scratch directory within the test folder (similar to build tests) + # This ensures Docker can access the built artifacts + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath("tmp", str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.makedirs(self.scratch_dir) + + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) + self.build_dir = Path(self.working_dir, ".aws-sam", "build") + self.built_template_path = self.build_dir.joinpath("template.yaml") + + # Use the original template path (not copied) + self.template_path = Path(self.test_data_path, "buildcmd", "language-extensions.yaml") + + def tearDown(self): + """Clean up scratch directory after test.""" + try: + shutil.rmtree(self.scratch_dir, ignore_errors=True) + except Exception: + pass + + def _get_python_version(self): + """Get the current Python version in the format expected by Lambda runtimes.""" + return f"python{sys.version_info.major}.{sys.version_info.minor}" + + def _make_parameter_override_arg(self, overrides): + """Create parameter override argument string.""" + return " ".join([f"ParameterKey={key},ParameterValue={value}" for key, value in overrides.items()]) + + @pytest.mark.flaky(reruns=3) + def test_invoke_foreach_expanded_alpha_function(self): + """ + Test invoking AlphaFunction from ForEach-expanded template. + + This test verifies: + 1. sam build successfully builds the language extensions template + 2. sam local invoke can invoke AlphaFunction (generated by Fn::ForEach) + 3. The function executes successfully (exit code 0) + 4. The output contains expected response + + Validates: Requirements 4.1, 12.2 + """ + # Use the current Python version for the runtime to avoid version mismatch + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Build the template + build_command = [ + get_sam_command(), + "build", + "-t", + str(self.template_path), + "-b", + str(self.build_dir), + "--parameter-overrides", + self._make_parameter_override_arg(overrides), + ] + + build_result = run_command(build_command, cwd=self.working_dir) + + self.assertEqual( + build_result.process.returncode, + 0, + f"sam build should succeed for language extensions template. stderr: {build_result.stderr.decode('utf-8')}", + ) + + # Verify the built template exists + self.assertTrue( + self.built_template_path.exists(), + f"Built template should exist at {self.built_template_path}", + ) + + # Verify AlphaFunction directory was created in build output + alpha_function_dir = self.build_dir.joinpath("AlphaFunction") + self.assertTrue( + alpha_function_dir.exists(), + f"AlphaFunction build artifacts should exist at {alpha_function_dir}", + ) + + # Invoke AlphaFunction using the built template + invoke_command = self.get_command_list( + function_to_invoke="AlphaFunction", + template_path=str(self.built_template_path), + no_event=True, + ) + stdout, stderr, invoke_exit_code = self.run_command(invoke_command, cwd=self.working_dir) + + # Verify the function executed successfully + self.assertEqual( + invoke_exit_code, + 0, + f"sam local invoke AlphaFunction should succeed. stdout: {stdout}, stderr: {stderr}", + ) + + # Verify the output contains expected response from the handler + # The handler returns "Hello World" + process_stdout = stdout.strip().decode("utf-8") + self.assertIn("Hello World", process_stdout, "Output should contain 'Hello World' from the handler response") + """ + Test invoking ConfigFunction from a template with language extensions. + + This test verifies that a regular function (not generated by ForEach) + in a template that uses AWS::LanguageExtensions can be invoked successfully. + + Validates: Requirements 4.1, 12.2 + """ + # Use the current Python version for the runtime to avoid version mismatch + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Build the template + build_command = [ + get_sam_command(), + "build", + "-t", + str(self.template_path), + "-b", + str(self.build_dir), + "--parameter-overrides", + self._make_parameter_override_arg(overrides), + ] + + build_result = run_command(build_command, cwd=self.working_dir) + + self.assertEqual( + build_result.process.returncode, + 0, + f"sam build should succeed for language extensions template. stderr: {build_result.stderr.decode('utf-8')}", + ) + + # Invoke ConfigFunction (a regular function in the template) + invoke_command = self.get_command_list( + function_to_invoke="ConfigFunction", + template_path=str(self.built_template_path), + no_event=True, + ) + stdout, stderr, invoke_exit_code = self.run_command(invoke_command, cwd=self.working_dir) + + # Verify the function executed successfully + self.assertEqual( + invoke_exit_code, + 0, + f"sam local invoke ConfigFunction should succeed. stderr: {stderr}", + ) + + # Verify the output contains expected response from the handler + # The handler returns "Hello World" + process_stdout = stdout.strip().decode("utf-8") + self.assertIn("Hello World", process_stdout, "Output should contain 'Hello World' from the handler response") + + @pytest.mark.flaky(reruns=3) + def test_invoke_foreach_expanded_beta_function(self): + """ + Test invoking BetaFunction from ForEach-expanded template. + + This test verifies that a different function generated by the same + Fn::ForEach loop can also be invoked successfully. + + Validates: Requirements 4.1, 12.2 + """ + # Use the current Python version for the runtime to avoid version mismatch + runtime = self._get_python_version() + overrides = {"Runtime": runtime} + + # Build the template + build_command = [ + get_sam_command(), + "build", + "-t", + str(self.template_path), + "-b", + str(self.build_dir), + "--parameter-overrides", + self._make_parameter_override_arg(overrides), + ] + + build_result = run_command(build_command, cwd=self.working_dir) + + self.assertEqual( + build_result.process.returncode, + 0, + f"sam build should succeed for language extensions template. stderr: {build_result.stderr.decode('utf-8')}", + ) + + # Verify BetaFunction directory was created in build output + beta_function_dir = self.build_dir.joinpath("BetaFunction") + self.assertTrue( + beta_function_dir.exists(), + f"BetaFunction build artifacts should exist at {beta_function_dir}", + ) + + # Invoke BetaFunction using the built template + invoke_command = self.get_command_list( + function_to_invoke="BetaFunction", + template_path=str(self.built_template_path), + no_event=True, + ) + stdout, stderr, invoke_exit_code = self.run_command(invoke_command, cwd=self.working_dir) + + # Verify the function executed successfully + self.assertEqual( + invoke_exit_code, + 0, + f"sam local invoke BetaFunction should succeed. stdout: {stdout}, stderr: {stderr}", + ) + + # Verify the output contains expected response from the handler + # The handler returns "Hello World" + process_stdout = stdout.strip().decode("utf-8") + self.assertIn("Hello World", process_stdout, "Output should contain 'Hello World' from the handler response") diff --git a/tests/integration/local/start_api/test_start_api_language_extensions.py b/tests/integration/local/start_api/test_start_api_language_extensions.py new file mode 100644 index 00000000000..f3bbc6d72eb --- /dev/null +++ b/tests/integration/local/start_api/test_start_api_language_extensions.py @@ -0,0 +1,96 @@ +""" +Integration tests for sam local start-api with CloudFormation Language Extensions. + +Tests verify that sam local start-api can serve API endpoints generated by Fn::ForEach +expansion from templates using AWS::LanguageExtensions transform. + +Requirements: +- 5.1: WHEN `sam local start-api` processes a template with `Fn::ForEach` generating + API Gateway resources, THE Start_API_Command SHALL serve all expanded API endpoints +- 5.2: WHEN an API endpoint is generated by `Fn::ForEach` with a path like `/alpha` and `/beta`, + THE Start_API_Command SHALL route requests to the correct expanded function +- 12.5: THE Integration_Tests SHALL verify `sam local start-api` serves API endpoints + generated by `Fn::ForEach` +""" + +import sys + +import pytest +import requests + +from .start_api_integ_base import StartApiIntegBaseClass + + +class TestStartApiLanguageExtensions(StartApiIntegBaseClass): + """ + Integration tests for sam local start-api with CloudFormation Language Extensions. + + This test class verifies that API endpoints generated by Fn::ForEach expansion + can be served using sam local start-api. + """ + + template_path = "/testdata/start_api/language-extensions-api.yaml" + + @classmethod + def setUpClass(cls): + # Set parameter overrides to use current Python version + python_version = f"python{sys.version_info.major}.{sys.version_info.minor}" + cls.parameter_overrides = {"Runtime": python_version} + super().setUpClass() + + def setUp(self): + self.url = f"http://127.0.0.1:{self.port}" + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_foreach_expanded_alpha_endpoint(self): + """ + Test that the /alpha endpoint generated by Fn::ForEach is served correctly. + + This test verifies: + 1. The /alpha endpoint is accessible + 2. The alphaFunction is invoked + 3. The response is correct + + Validates: Requirements 5.1, 5.2, 12.5 + """ + response = requests.get(f"{self.url}/alpha", timeout=300) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"hello": "world"}) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_foreach_expanded_beta_endpoint(self): + """ + Test that the /beta endpoint generated by Fn::ForEach is served correctly. + + This test verifies: + 1. The /beta endpoint is accessible + 2. The betaFunction is invoked + 3. The response is correct + + Validates: Requirements 5.1, 5.2, 12.5 + """ + response = requests.get(f"{self.url}/beta", timeout=300) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"hello": "world"}) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_both_foreach_endpoints_accessible(self): + """ + Test that both /alpha and /beta endpoints are accessible in the same session. + + This test verifies that all ForEach-expanded endpoints are served simultaneously. + + Validates: Requirements 5.1, 12.5 + """ + alpha_response = requests.get(f"{self.url}/alpha", timeout=300) + beta_response = requests.get(f"{self.url}/beta", timeout=300) + + self.assertEqual(alpha_response.status_code, 200) + self.assertEqual(beta_response.status_code, 200) + self.assertEqual(alpha_response.json(), {"hello": "world"}) + self.assertEqual(beta_response.json(), {"hello": "world"}) diff --git a/tests/integration/testdata/start_api/language-extensions-api.yaml b/tests/integration/testdata/start_api/language-extensions-api.yaml new file mode 100644 index 00000000000..dd337a3b24a --- /dev/null +++ b/tests/integration/testdata/start_api/language-extensions-api.yaml @@ -0,0 +1,42 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Test template for CloudFormation Language Extensions with API Gateway. + Demonstrates Fn::ForEach usage for generating multiple API endpoints. + +Parameters: + Runtime: + Type: String + Default: python3.12 + Description: Python runtime version for Lambda functions + +Globals: + Function: + Timeout: 20 + MemorySize: 512 + Runtime: !Ref Runtime + +Resources: + # Test Fn::ForEach generating multiple Lambda functions with API events + # This generates AlphaFunction and BetaFunction with /alpha and /beta endpoints + Fn::ForEach::ApiFunctions: + - FunctionName + - - alpha + - beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: . + Events: + ApiEvent: + Type: Api + Properties: + Path: !Sub /${FunctionName} + Method: get + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} diff --git a/tests/integration/testdata/validate/language-extensions/cloud-dependent-collection.yaml b/tests/integration/testdata/validate/language-extensions/cloud-dependent-collection.yaml new file mode 100644 index 00000000000..8a9990b1967 --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/cloud-dependent-collection.yaml @@ -0,0 +1,39 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Invalid template with Fn::ForEach using cloud-dependent collection. + Used for testing sam validate error handling with language extensions. + This template should fail validation because Fn::GetAtt cannot be resolved locally. + Validates: Requirements 5.1, 5.4, 5.5 + +Resources: + # A DynamoDB table that provides the collection values + ConfigTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ConfigTable + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + BillingMode: PAY_PER_REQUEST + + # Invalid Fn::ForEach - collection uses Fn::GetAtt which cannot be resolved locally + # This should fail with a clear error message suggesting parameter workaround + Fn::ForEach::Functions: + - FunctionName + - !GetAtt ConfigTable.StreamArn + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./src + Runtime: python3.9 + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} diff --git a/tests/integration/testdata/validate/language-extensions/invalid-foreach.yaml b/tests/integration/testdata/validate/language-extensions/invalid-foreach.yaml new file mode 100644 index 00000000000..397c8e2bbf7 --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/invalid-foreach.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Invalid template with incorrect Fn::ForEach syntax. + Used for testing sam validate error handling with language extensions. + This template should fail validation due to wrong number of arguments. + +Resources: + # Invalid Fn::ForEach - missing the template body (3rd element) + # Fn::ForEach requires exactly 3 elements: [identifier, collection, template] + # This only has 2 elements, which should cause a validation error + Fn::ForEach::InvalidFunctions: + - FunctionName + - - Alpha + - Beta + # Missing the third element (template body) - this is intentionally invalid diff --git a/tests/integration/testdata/validate/language-extensions/missing-dynamic-artifact-dir.yaml b/tests/integration/testdata/validate/language-extensions/missing-dynamic-artifact-dir.yaml new file mode 100644 index 00000000000..c8a5116be39 --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/missing-dynamic-artifact-dir.yaml @@ -0,0 +1,27 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Template with Fn::ForEach using dynamic CodeUri pointing to non-existent directories. + Used for testing sam validate error handling with language extensions. + This template should fail validation because the directories don't exist. + Validates: Requirements 4.1 + +Resources: + # Fn::ForEach with dynamic CodeUri pointing to non-existent directories + # The directories ./NonExistentAlpha and ./NonExistentBeta don't exist + Fn::ForEach::Functions: + - FunctionName + - - NonExistentAlpha + - NonExistentBeta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./${FunctionName} + Runtime: python3.9 + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} diff --git a/tests/integration/testdata/validate/language-extensions/valid-dynamic-codeuri.yaml b/tests/integration/testdata/validate/language-extensions/valid-dynamic-codeuri.yaml new file mode 100644 index 00000000000..8236196478e --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/valid-dynamic-codeuri.yaml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Valid template with Fn::ForEach using dynamic CodeUri. + Used for testing sam validate with language extensions. + Validates: Requirements 4.1, 10.1 + +Resources: + # Fn::ForEach with dynamic CodeUri (Requirement 4.1) + # This generates AlphaFunction and BetaFunction with different CodeUri paths + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./${FunctionName} + Runtime: python3.9 + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} diff --git a/tests/integration/testdata/validate/language-extensions/valid-foreach.yaml b/tests/integration/testdata/validate/language-extensions/valid-foreach.yaml new file mode 100644 index 00000000000..361e62f1cb0 --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/valid-foreach.yaml @@ -0,0 +1,34 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Valid template with Fn::ForEach generating multiple Lambda functions. + Used for testing sam validate with language extensions. + Validates: Requirements 13.1, 13.6 + +Resources: + # Fn::ForEach generating multiple Lambda functions (Requirement 13.1) + # This generates AlphaFunction and BetaFunction + Fn::ForEach::Functions: + - FunctionName + - - Alpha + - Beta + - ${FunctionName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./src + Runtime: python3.9 + FunctionName: + Fn::Sub: ${FunctionName}-handler + Environment: + Variables: + FUNCTION_NAME: !Sub ${FunctionName} + + # Additional resource to demonstrate combining multiple features (Requirement 13.6) + SharedBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub shared-bucket-${AWS::AccountId} diff --git a/tests/integration/testdata/validate/language-extensions/valid-tojsonstring.yaml b/tests/integration/testdata/validate/language-extensions/valid-tojsonstring.yaml new file mode 100644 index 00000000000..c132e205fcb --- /dev/null +++ b/tests/integration/testdata/validate/language-extensions/valid-tojsonstring.yaml @@ -0,0 +1,56 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Description: > + Valid template demonstrating Fn::ToJsonString usage in function environment variables. + Used for testing sam validate with language extensions. + Validates: Requirements 13.2, 13.6 + +Resources: + # Lambda function with Fn::ToJsonString in environment variables (Requirement 13.2) + ConfigFunction: + Type: AWS::Serverless::Function + Properties: + Handler: main.handler + CodeUri: ./src + Runtime: python3.9 + Environment: + Variables: + # Fn::ToJsonString converts an object to a JSON string + CONFIG_JSON: + Fn::ToJsonString: + setting1: value1 + setting2: value2 + nested: + key: value + # Fn::ToJsonString with array + ITEMS_JSON: + Fn::ToJsonString: + - Alpha + - Beta + - Gamma + + # Another function demonstrating Fn::ToJsonString with Fn::Length (Requirement 13.6) + MetricsFunction: + Type: AWS::Serverless::Function + Properties: + Handler: metrics.handler + CodeUri: ./src + Runtime: python3.9 + Environment: + Variables: + # Combining Fn::ToJsonString with other data + METRICS_CONFIG: + Fn::ToJsonString: + enabled: true + endpoints: + - /health + - /metrics + retryCount: 3 + # Fn::Length for array operations + ENDPOINT_COUNT: + Fn::Length: + - /health + - /metrics diff --git a/tests/integration/validate/test_validate_command.py b/tests/integration/validate/test_validate_command.py index 70c2fc99660..e6298941989 100644 --- a/tests/integration/validate/test_validate_command.py +++ b/tests/integration/validate/test_validate_command.py @@ -283,3 +283,310 @@ def test_lint_invalid_template(self): self.assertIn(warning_message, output) self.assertEqual(command_result.process.returncode, 1) + + def test_validate_language_extensions_valid_foreach(self): + """ + Test that sam validate passes for valid Fn::ForEach syntax. + + Validates: Requirements 7.1, 12.3 + - 7.1: WHEN `sam validate` processes a template with valid `Fn::ForEach` syntax, + THE Validate_Command SHALL report the template as valid + - 12.3: THE Integration_Tests SHALL verify `sam validate` correctly validates + templates with language extensions + """ + test_data_path = ( + Path(__file__).resolve().parents[2] / "integration" / "testdata" / "validate" / "language-extensions" + ) + template_file = "valid-foreach.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + output = command_result.stdout.decode("utf-8") + + # Verify the command succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, + 0, + f"Expected exit code 0 but got {command_result.process.returncode}. Output: {output}", + ) + + # Verify the output indicates the template is valid + # The output should contain a message indicating the template is valid + valid_pattern = re.compile( + r"valid-foreach\.yaml is a valid SAM Template\. This is according to basic SAM Validation, " + 'for additional validation, please run with "--lint" option(\r\n)?$' + ) + self.assertRegex( + output, + valid_pattern, + f"Expected output to indicate template is valid. Actual output: {output}", + ) + + def test_validate_language_extensions_invalid_foreach(self): + """ + Test that sam validate fails with clear error message for invalid Fn::ForEach syntax. + + Validates: Requirements 7.2, 12.4 + - 7.2: WHEN `sam validate` processes a template with invalid `Fn::ForEach` syntax + (e.g., wrong number of arguments), THE Validate_Command SHALL report a + clear error message + - 12.4: THE Integration_Tests SHALL verify `sam validate` reports errors for + invalid language extension syntax + """ + test_data_path = ( + Path(__file__).resolve().parents[2] / "integration" / "testdata" / "validate" / "language-extensions" + ) + template_file = "invalid-foreach.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + # Combine stdout and stderr for error message checking + stdout_output = command_result.stdout.decode("utf-8") + stderr_output = command_result.stderr.decode("utf-8") + combined_output = stdout_output + stderr_output + + # Verify the command fails (non-zero exit code) + self.assertNotEqual( + command_result.process.returncode, + 0, + f"Expected non-zero exit code but got {command_result.process.returncode}. Output: {combined_output}", + ) + + # Verify the output contains an error message about the invalid Fn::ForEach syntax + # The error should mention Fn::ForEach and indicate the syntax issue + # Check for common error indicators related to Fn::ForEach validation failure + error_indicators = [ + "Fn::ForEach", + "ForEach", + "Invalid", + "Error", + ] + + has_error_indicator = any(indicator.lower() in combined_output.lower() for indicator in error_indicators) + self.assertTrue( + has_error_indicator, + f"Expected error message about invalid Fn::ForEach syntax. Actual output: {combined_output}", + ) + + def test_validate_language_extensions_valid_dynamic_codeuri(self): + """ + Test that sam validate passes for valid Fn::ForEach with dynamic CodeUri. + + Validates: Requirements 4.1, 10.1 + - 4.1: Dynamic artifact properties (e.g., CodeUri: ./${Name}) are supported + - 10.1: sam validate should pass for valid templates with dynamic CodeUri + """ + test_data_path = ( + Path(__file__).resolve().parents[2] / "integration" / "testdata" / "validate" / "language-extensions" + ) + template_file = "valid-dynamic-codeuri.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + output = command_result.stdout.decode("utf-8") + + # Verify the command succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, + 0, + f"Expected exit code 0 but got {command_result.process.returncode}. Output: {output}", + ) + + # Verify the output indicates the template is valid + valid_pattern = re.compile( + r"valid-dynamic-codeuri\.yaml is a valid SAM Template\. This is according to basic SAM Validation, " + 'for additional validation, please run with "--lint" option(\r\n)?$' + ) + self.assertRegex( + output, + valid_pattern, + f"Expected output to indicate template is valid. Actual output: {output}", + ) + + def test_validate_language_extensions_cloud_dependent_collection(self): + """ + Test that sam validate fails with clear error for cloud-dependent collection. + + Validates: Requirements 5.1, 5.4, 5.5 + - 5.1: Fn::GetAtt in collection should raise error with clear message + - 5.4: Error message should include which Fn::ForEach block has the issue + - 5.5: Error message should suggest using parameter with --parameter-overrides + """ + test_data_path = ( + Path(__file__).resolve().parents[2] / "integration" / "testdata" / "validate" / "language-extensions" + ) + template_file = "cloud-dependent-collection.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + # Combine stdout and stderr for error message checking + stdout_output = command_result.stdout.decode("utf-8") + stderr_output = command_result.stderr.decode("utf-8") + combined_output = stdout_output + stderr_output + + # Verify the command fails (non-zero exit code) + self.assertNotEqual( + command_result.process.returncode, + 0, + f"Expected non-zero exit code but got {command_result.process.returncode}. Output: {combined_output}", + ) + + # Verify the output contains an error message about the cloud-dependent collection + # The error should mention Fn::GetAtt or indicate the collection cannot be resolved + error_indicators = [ + "GetAtt", + "collection", + "resolve", + "Error", + "cannot", + "not supported", + ] + + has_error_indicator = any(indicator.lower() in combined_output.lower() for indicator in error_indicators) + self.assertTrue( + has_error_indicator, + f"Expected error message about cloud-dependent collection. Actual output: {combined_output}", + ) + + def test_validate_language_extensions_missing_dynamic_artifact_dir(self): + """ + Test that sam validate handles templates with dynamic CodeUri pointing to non-existent directories. + + Validates: Requirements 4.1 + - 4.1: Dynamic artifact properties should be validated + + Note: sam validate performs basic SAM validation which may not check if directories exist. + This test verifies the behavior when dynamic CodeUri points to non-existent directories. + """ + test_data_path = ( + Path(__file__).resolve().parents[2] / "integration" / "testdata" / "validate" / "language-extensions" + ) + template_file = "missing-dynamic-artifact-dir.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + stdout_output = command_result.stdout.decode("utf-8") + stderr_output = command_result.stderr.decode("utf-8") + combined_output = stdout_output + stderr_output + + # sam validate performs basic SAM validation which may pass even if directories don't exist + # The actual directory validation happens during sam build + # This test documents the current behavior + # If validation passes, that's acceptable for basic validation + # If validation fails, it should have a clear error message + if command_result.process.returncode != 0: + # If it fails, verify there's a meaningful error message + error_indicators = [ + "not found", + "does not exist", + "missing", + "Error", + "cannot", + ] + has_error_indicator = any(indicator.lower() in combined_output.lower() for indicator in error_indicators) + self.assertTrue( + has_error_indicator, + f"Expected error message about missing directory. Actual output: {combined_output}", + ) + # If it passes, that's also acceptable for basic validation + # The directory check happens during sam build + + def test_validate_language_extensions_nested_foreach_valid_depth_5(self): + """ + Test that sam validate passes for templates with 5 levels of nested Fn::ForEach. + + Validates: Requirements 18.2, 18.6 + - 18.2: WHEN a template contains 5 or fewer levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL process the template successfully + - 18.6: WHEN `sam validate` processes a template exceeding the nested loop limit, + THE Validate_Command SHALL report the nesting depth error + """ + test_data_path = ( + Path(__file__).resolve().parents[2] + / "integration" + / "testdata" + / "buildcmd" + / "language-extensions-nested-foreach-valid" + ) + template_file = "template.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + output = command_result.stdout.decode("utf-8") + + # Verify the command succeeds (exit code 0) + self.assertEqual( + command_result.process.returncode, + 0, + f"Expected exit code 0 but got {command_result.process.returncode}. Output: {output}", + ) + + # Verify the output indicates the template is valid + valid_pattern = re.compile( + r"template\.yaml is a valid SAM Template\. This is according to basic SAM Validation, " + 'for additional validation, please run with "--lint" option(\r\n)?$' + ) + self.assertRegex( + output, + valid_pattern, + f"Expected output to indicate template is valid. Actual output: {output}", + ) + + def test_validate_language_extensions_nested_foreach_invalid_depth_6(self): + """ + Test that sam validate fails for templates with 6 levels of nested Fn::ForEach. + + Validates: Requirements 18.3, 18.4, 18.5, 18.6 + - 18.3: WHEN a template contains more than 5 levels of nested Fn::ForEach loops, + THE SAM_CLI SHALL raise an error before processing + - 18.4: WHEN the nested loop limit is exceeded, THE error message SHALL clearly + indicate that the maximum nesting depth of 5 has been exceeded + - 18.5: WHEN the nested loop limit is exceeded, THE error message SHALL indicate + the actual nesting depth found in the template + - 18.6: WHEN `sam validate` processes a template exceeding the nested loop limit, + THE Validate_Command SHALL report the nesting depth error + """ + test_data_path = ( + Path(__file__).resolve().parents[2] + / "integration" + / "testdata" + / "buildcmd" + / "language-extensions-nested-foreach-invalid" + ) + template_file = "template.yaml" + template_path = test_data_path / template_file + + command_result = run_command(self.command_list(template_file=template_path)) + # Combine stdout and stderr for error message checking + stdout_output = command_result.stdout.decode("utf-8") + stderr_output = command_result.stderr.decode("utf-8") + combined_output = stdout_output + stderr_output + + # Verify the command fails (non-zero exit code) + self.assertNotEqual( + command_result.process.returncode, + 0, + f"Expected non-zero exit code but got {command_result.process.returncode}. Output: {combined_output}", + ) + + # Requirement 18.4: Error message indicates maximum nesting depth of 5 + self.assertIn( + "5", + combined_output, + f"Expected error message to mention maximum depth of 5. Actual output: {combined_output}", + ) + + # Requirement 18.5: Error message indicates actual nesting depth found (6) + self.assertIn( + "6", + combined_output, + f"Expected error message to mention actual depth of 6. Actual output: {combined_output}", + ) + + # Verify the error message mentions nesting or depth + nesting_indicators = ["nesting", "depth", "exceeds", "maximum", "nested"] + has_nesting_indicator = any(indicator.lower() in combined_output.lower() for indicator in nesting_indicators) + self.assertTrue( + has_nesting_indicator, + f"Expected error message about nesting depth. Actual output: {combined_output}", + ) From e64121dad82aee7e8417265ecebbea4ed1c10177 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 18 Feb 2026 00:10:19 +0000 Subject: [PATCH 18/21] chore: skip cfn_language_extensions tests in default make test target Add make test-lang-ext and make test-all targets so the 1695 language extensions unit tests only run when needed, keeping the default make test fast for unrelated PRs. --- .coveragerc_no_lang_ext | 15 +++++++++++++++ Makefile | 28 +++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 .coveragerc_no_lang_ext diff --git a/.coveragerc_no_lang_ext b/.coveragerc_no_lang_ext new file mode 100644 index 00000000000..f38ef5303cd --- /dev/null +++ b/.coveragerc_no_lang_ext @@ -0,0 +1,15 @@ +# Coverage config for `make test` which skips cfn_language_extensions tests. +# Extends the base .coveragerc and also excludes the language extensions source +# so coverage % isn't penalized when those tests are skipped. +[run] +branch = True +omit = + samcli/lib/cfn_language_extensions/* + # Inherited from .coveragerc + samcli/lib/iac/plugins_interfaces.py + samcli/lib/init/templates/* + samcli/hook_packages/terraform/copy_terraform_built_artifacts.py +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError.* diff --git a/Makefile b/Makefile index 7dc11c64509..1cd584ee8f3 100644 --- a/Makefile +++ b/Makefile @@ -13,11 +13,19 @@ init: fi test: - # Run unit tests and fail if coverage falls below 94% + # Run unit tests (excluding cfn_language_extensions) and fail if coverage falls below 94% + pytest --cov samcli --cov schema --cov-report term-missing --cov-fail-under 94 tests/unit --ignore=tests/unit/lib/cfn_language_extensions --cov-config=.coveragerc_no_lang_ext + +test-lang-ext: + # Run cfn_language_extensions unit tests with coverage + pytest --cov samcli.lib.cfn_language_extensions --cov-report term-missing --cov-fail-under 94 tests/unit/lib/cfn_language_extensions + +test-all: + # Run all unit tests including cfn_language_extensions pytest --cov samcli --cov schema --cov-report term-missing --cov-fail-under 94 tests/unit test-cov-report: - # Run unit tests with html coverage report + # Run all unit tests with html coverage report pytest --cov samcli --cov schema --cov-report html --cov-fail-under 94 tests/unit integ-test: @@ -45,7 +53,17 @@ lint: mypy --exclude /testdata/ --exclude /init/templates/ --no-incremental setup.py samcli tests schema # Command to run everytime you make changes to verify everything works -dev: lint test +# Runs test-all if cfn_language_extensions files changed, otherwise test +dev: lint + @if git diff --name-only origin/develop... 2>/dev/null | grep -qE 'cfn_language_extensions/'; then \ + echo "Detected cfn_language_extensions changes — running all tests"; \ + $(MAKE) test-all; \ + else \ + $(MAKE) test; \ + fi + +# Run full verification including language extensions tests +dev-all: lint test-all black: black setup.py samcli tests schema @@ -59,8 +77,8 @@ format: black schema: python -m schema.make_schema -# Verifications to run before sending a pull request -pr: init schema black-check dev +# Verifications to run before sending a pull request — runs ALL tests +pr: init schema black-check lint test-all # lucashuy: Linux and MacOS are on the same Python version, # however we should follow up in a different change From 26d6dea025be8f080e4780d37f78f6cf2e07e4e5 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 18 Feb 2026 20:32:00 +0000 Subject: [PATCH 19/21] fix: remove expansion cache to fix sync watch nested stack failures The expand_language_extensions() cache stored references to template dicts that were later mutated in-place by ApplicationBuilder.update_template() (which changes nested stack Location properties to build-output paths). On cache hit, the mutated dict was returned, causing TemplateNotFoundException during the second infra sync in sam sync --watch. Remove the cache entirely since deep-copying on hit negates the performance benefit and adds complexity. Keep clear_expansion_cache() as a no-op for backward compatibility. Fixes TestSyncInfraNestedStacks_0 and TestSyncInfraNestedStacks_1 integration test failures. --- .github/workflows/test-sync-fix.yml | 147 +++++ .../sam_integration.py | 77 +-- .../test_sam_integration.py | 75 ++- .../test_template_cache.py | 504 ------------------ 4 files changed, 189 insertions(+), 614 deletions(-) create mode 100644 .github/workflows/test-sync-fix.yml delete mode 100644 tests/unit/lib/cfn_language_extensions/test_template_cache.py diff --git a/.github/workflows/test-sync-fix.yml b/.github/workflows/test-sync-fix.yml new file mode 100644 index 00000000000..bdb80e32d9f --- /dev/null +++ b/.github/workflows/test-sync-fix.yml @@ -0,0 +1,147 @@ +name: Test Sync Fix - Cache Removal + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_DEFAULT_REGION: us-east-1 + SAM_CLI_DEV: 1 + SAM_CLI_TELEMETRY: 0 + SAM_CLI_CONTAINER_CONNECTION_TIMEOUT: 60 + NOSE_PARAMETERIZED_NO_WARN: 1 + BY_CANARY: true + UV_PYTHON: python3.11 + CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} + ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} + +jobs: + sync-tests: + name: Sync Integration Tests (docker) + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Free up disk space + run: | + sudo rm -rf /usr/local/share/powershell /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo apt-get clean + docker system prune -af --volumes || true + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.OIDC_ROLE_ARN }} + aws-region: us-east-1 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: | + 3.11 + 3.9 + 3.10 + 3.12 + 3.13 + 3.14 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Java 25 + uses: actions/setup-java@v5 + with: + distribution: 'corretto' + java-version: '25' + + - name: Install Maven and Gradle + run: | + sudo apt-get remove -y maven || true + wget https://dlcdn.apache.org/maven/maven-3/3.9.12/binaries/apache-maven-3.9.12-bin.zip -P /tmp + sudo unzip -d /opt/mvn /tmp/apache-maven-*.zip + wget https://services.gradle.org/distributions/gradle-9.2.0-bin.zip -P /tmp + sudo unzip -d /opt/gradle /tmp/gradle-*.zip + sudo ln -sf /opt/mvn/apache-maven-3.9.12/bin/mvn /usr/local/bin/mvn + sudo ln -sf /opt/gradle/gradle-9.2.0/bin/gradle /usr/local/bin/gradle + echo "/opt/mvn/apache-maven-3.9.12/bin" >> $GITHUB_PATH + echo "/opt/gradle/gradle-9.2.0/bin" >> $GITHUB_PATH + echo "MAVEN_HOME=/opt/mvn/apache-maven-3.9.12" >> $GITHUB_ENV + + - name: Set up Ruby 3.4.7 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4.7' + + - name: Setup QEMU for ARM64 emulation + run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + + - name: Initialize project + run: make init + + - name: Get testing resources and credentials + run: | + test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) + if [ $? -ne 0 ]; then + test_env_var=$(python3.11 tests/get_testing_resources.py) + if [ $? -ne 0 ]; then + echo "Failed to acquire credentials or test resources." + exit 1 + fi + fi + + echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV + + TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") + TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") + TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") + TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") + + echo "::add-mask::$TEST_ACCESS_KEY_ID" + echo "::add-mask::$TEST_SECRET_ACCESS_KEY" + echo "::add-mask::$TEST_SESSION_TOKEN" + echo "::add-mask::$TEST_TASK_TOKEN" + + echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV + echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV + echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV + echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV + echo "AWS_KMS_KEY=$(echo "$test_env_var" | jq -j ".TestKMSKeyArn")" >> $GITHUB_ENV + echo "AWS_SIGNING_PROFILE_NAME=$(echo "$test_env_var" | jq -j ".TestSigningProfileName")" >> $GITHUB_ENV + echo "AWS_SIGNING_PROFILE_VERSION_ARN=$(echo "$test_env_var" | jq -j ".TestSigningProfileARN")" >> $GITHUB_ENV + + - name: Login to Public ECR + run: | + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + + - name: Run sync watch nested stack tests + run: | + pytest -vv tests/integration/sync/test_sync_watch.py -k "TestSyncInfraNestedStacks" --reruns 1 --json-report --json-report-file=TEST_REPORT-sync-nested.json + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results-sync-fix + path: TEST_REPORT-*.json + + - name: Reset test account + if: always() + run: | + export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID + export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY + export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN + aws lambda invoke \ + --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ + --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ + ./lambda-output.txt \ + --region us-west-2 \ + --cli-binary-format raw-in-base64-out + cat ./lambda-output.txt diff --git a/samcli/lib/cfn_language_extensions/sam_integration.py b/samcli/lib/cfn_language_extensions/sam_integration.py index ce665b6d13b..6ba13eecf0e 100644 --- a/samcli/lib/cfn_language_extensions/sam_integration.py +++ b/samcli/lib/cfn_language_extensions/sam_integration.py @@ -16,7 +16,6 @@ import copy import logging -import os import re from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple @@ -33,13 +32,6 @@ # Transform name for AWS Language Extensions AWS_LANGUAGE_EXTENSIONS_TRANSFORM = "AWS::LanguageExtensions" -# Template-level cache for language extension expansion results. -# Key: (template_path, file_mtime, parameter_values_hash) -# Value: LanguageExtensionResult -# Scoped to a single CLI process; cleared by clear_expansion_cache() -# in warm container refresh and test fixtures. -_expansion_cache: Dict[Tuple[str, float, int], "LanguageExtensionResult"] = {} - @dataclass(frozen=True) class LanguageExtensionResult: @@ -436,39 +428,16 @@ def detect_dynamic_artifact_properties( return dynamic_properties -def _hash_params(parameter_values: Optional[Dict[str, Any]]) -> int: - """ - Compute a stable hash for parameter values to use as part of the cache key. - - Parameters - ---------- - parameter_values : dict, optional - Template parameter values (may contain nested dicts/lists from - intrinsic functions like {"Ref": "..."}) - - Returns - ------- - int - Hash of the parameter values - """ - if not parameter_values: - return hash(()) - # Use json.dumps with sort_keys for a stable, hashable representation - # that handles nested dicts/lists from intrinsic functions. - import json - - return hash(json.dumps(parameter_values, sort_keys=True, default=str)) - - def clear_expansion_cache() -> None: """ - Clear the template-level expansion cache. + No-op kept for backward compatibility. - Called by warm container refresh (sam local start-api / start-lambda) - when template files change on disk, and by test fixtures to ensure - isolation between test cases. + Previously cleared a template-level expansion cache that was removed + because it caused cache poisoning when downstream code mutated + template dicts in-place (e.g., ApplicationBuilder.update_template + during sam sync --watch). """ - _expansion_cache.clear() + pass def expand_language_extensions( @@ -487,10 +456,6 @@ def expand_language_extensions( 5. Calls process_template_for_sam_cli() for expansion 6. Returns a LanguageExtensionResult with all outputs - If template_path is provided, results are cached by - (template_path, file_mtime, parameter_values_hash) to avoid redundant - expansions within a single CLI command invocation. - If the template does not contain the AWS::LanguageExtensions transform, returns early with had_language_extensions=False and the original template unchanged. @@ -502,8 +467,7 @@ def expand_language_extensions( parameter_values : dict, optional Template parameter values (may include pseudo-parameters like AWS::Region) template_path : str, optional - Path to the template file on disk. When provided, enables caching - keyed on (path, mtime, params_hash). + Path to the template file on disk (unused, kept for API compatibility). Returns ------- @@ -518,30 +482,13 @@ def expand_language_extensions( """ from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException - # Check cache if template_path is provided - cache_key = None - if template_path: - try: - mtime = os.path.getmtime(template_path) - params_hash = _hash_params(parameter_values) - cache_key = (template_path, mtime, params_hash) - if cache_key in _expansion_cache: - LOG.debug("Cache hit for language extension expansion: %s", template_path) - return _expansion_cache[cache_key] - except OSError: - LOG.debug("Could not stat template file for caching: %s", template_path) - cache_key = None - if not check_using_language_extension(template): - result = LanguageExtensionResult( + return LanguageExtensionResult( expanded_template=template, original_template=template, dynamic_artifact_properties=[], had_language_extensions=False, ) - if cache_key is not None: - _expansion_cache[cache_key] = result - return result LOG.debug("Expanding CloudFormation Language Extensions (Phase 1)") @@ -563,19 +510,13 @@ def expand_language_extensions( LOG.debug("Successfully expanded CloudFormation Language Extensions") - result = LanguageExtensionResult( + return LanguageExtensionResult( expanded_template=expanded_template, original_template=original_template, dynamic_artifact_properties=dynamic_properties, had_language_extensions=True, ) - # Store result in cache on miss - if cache_key is not None: - _expansion_cache[cache_key] = result - - return result - except Exception as e: from samcli.lib.cfn_language_extensions.exceptions import ( InvalidTemplateException as LangExtInvalidTemplateException, diff --git a/tests/unit/lib/cfn_language_extensions/test_sam_integration.py b/tests/unit/lib/cfn_language_extensions/test_sam_integration.py index 596993d37e5..5bb0ef417e8 100644 --- a/tests/unit/lib/cfn_language_extensions/test_sam_integration.py +++ b/tests/unit/lib/cfn_language_extensions/test_sam_integration.py @@ -509,14 +509,11 @@ def test_plugin_with_null_transform(self): # Coverage Tests for sam_integration.py # ============================================================================= -import os -import tempfile from unittest.mock import patch from samcli.lib.cfn_language_extensions.sam_integration import ( LanguageExtensionResult, _build_pseudo_parameters, - _hash_params, check_using_language_extension, clear_expansion_cache, contains_loop_variable, @@ -951,31 +948,43 @@ def test_multiple_foreach_blocks(self): class TestExpandLanguageExtensionsEdgeCases: """Tests for expand_language_extensions edge cases.""" - def setup_method(self): - clear_expansion_cache() - - def teardown_method(self): - clear_expansion_cache() - - def test_oserror_on_getmtime_disables_caching(self): + def test_nonexistent_template_path_does_not_error(self): template = {"Transform": "AWS::Serverless-2016-10-31", "Resources": {}} result = expand_language_extensions(template, template_path="/nonexistent/path/template.yaml") assert result.had_language_extensions is False - def test_non_language_extension_template_cached_when_path_provided(self): - with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w") as f: - f.write("Resources: {}") - f.flush() - path = f.name - - try: - template = {"Resources": {}} - result1 = expand_language_extensions(template, template_path=path) - result2 = expand_language_extensions(template, template_path=path) - assert result1 is result2 # Same object from cache - assert result1.had_language_extensions is False - finally: - os.unlink(path) + def test_non_language_extension_template_returns_same_dict(self): + template = {"Resources": {}} + result = expand_language_extensions(template) + # When no language extensions, the original template dict is returned as-is + assert result.expanded_template is template + assert result.had_language_extensions is False + + def test_mutation_does_not_affect_subsequent_calls(self): + """Mutating a returned result must not affect subsequent calls.""" + template = { + "Resources": { + "MyStack": { + "Type": "AWS::Serverless::Application", + "Properties": {"Location": "./child.yaml"}, + } + } + } + result1 = expand_language_extensions(template) + # Simulate what update_template does: mutate Location in-place + result1.expanded_template["Resources"]["MyStack"]["Properties"]["Location"] = "SomeOther/template.yaml" + + # A fresh template dict should not be affected + template2 = { + "Resources": { + "MyStack": { + "Type": "AWS::Serverless::Application", + "Properties": {"Location": "./child.yaml"}, + } + } + } + result2 = expand_language_extensions(template2) + assert result2.expanded_template["Resources"]["MyStack"]["Properties"]["Location"] == "./child.yaml" def test_non_invalid_template_exception_reraised(self): template = { @@ -1063,21 +1072,3 @@ def test_frozen(self): result.had_language_extensions = True -class TestHashParams: - """Edge case tests for _hash_params.""" - - def test_non_string_values(self): - h1 = _hash_params({"key": 123}) - h2 = _hash_params({"key": 123}) - assert h1 == h2 - - def test_nested_dict_values_are_hashable(self): - """Nested dicts (e.g. intrinsic functions) should be hashable.""" - h1 = _hash_params({"key": {"Ref": "SomeParam"}}) - h2 = _hash_params({"key": {"Ref": "SomeParam"}}) - assert h1 == h2 - - def test_different_nested_values_produce_different_hash(self): - h1 = _hash_params({"key": {"Ref": "ParamA"}}) - h2 = _hash_params({"key": {"Ref": "ParamB"}}) - assert h1 != h2 diff --git a/tests/unit/lib/cfn_language_extensions/test_template_cache.py b/tests/unit/lib/cfn_language_extensions/test_template_cache.py deleted file mode 100644 index 259fc859eb8..00000000000 --- a/tests/unit/lib/cfn_language_extensions/test_template_cache.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Unit tests for Template-Level Cache (Tasks 36.1-36.2). - -This module covers: -- Task 36.1: Cache hit/miss behavior tests -- Task 36.2: Cache scoping and invalidation tests - -Requirements tested: - - 24.1: Cache hit returns same LanguageExtensionResult without re-expanding - - 24.2: Cache miss on different template path triggers re-expansion - - 24.3: Cache miss on different mtime triggers re-expansion - - 24.4: Cache miss on different parameter values triggers re-expansion - - 24.5: Cache is scoped to a single command invocation - - 24.6: Cache invalidation works for warm container file change events -""" - -import os -from unittest import TestCase -from unittest.mock import patch, MagicMock - -from samcli.lib.cfn_language_extensions.sam_integration import ( - LanguageExtensionResult, - expand_language_extensions, - clear_expansion_cache, - _expansion_cache, - _hash_params, -) - - -def _make_language_extensions_template(resource_name="AlphaFunction"): - """Helper to create a minimal template with AWS::LanguageExtensions transform.""" - return { - "AWSTemplateFormatVersion": "2010-09-09", - "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], - "Resources": { - "Fn::ForEach::Functions": [ - "Name", - ["Alpha", "Beta"], - { - "${Name}Function": { - "Type": "AWS::Serverless::Function", - "Properties": { - "Handler": "${Name}.handler", - "CodeUri": "./src", - "Runtime": "python3.9", - }, - } - }, - ] - }, - } - - -def _make_expanded_template(): - """Helper to create a mock expanded template.""" - return { - "AWSTemplateFormatVersion": "2010-09-09", - "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], - "Resources": { - "AlphaFunction": { - "Type": "AWS::Serverless::Function", - "Properties": { - "Handler": "Alpha.handler", - "CodeUri": "./src", - "Runtime": "python3.9", - }, - }, - "BetaFunction": { - "Type": "AWS::Serverless::Function", - "Properties": { - "Handler": "Beta.handler", - "CodeUri": "./src", - "Runtime": "python3.9", - }, - }, - }, - } - - -# ============================================================================= -# Task 36.1: Unit Tests for Cache Hit/Miss Behavior -# ============================================================================= - - -class TestCacheHitMissBehavior(TestCase): - """ - Unit tests for cache hit/miss behavior. - - Validates: Requirements 24.1, 24.2, 24.3, 24.4 - """ - - def setUp(self): - """Clear the expansion cache before each test.""" - clear_expansion_cache() - - def tearDown(self): - """Clear the expansion cache after each test.""" - clear_expansion_cache() - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_hit_returns_same_result_without_re_expanding(self, mock_getmtime, mock_process): - """ - Test that a cache hit returns the same LanguageExtensionResult without - calling process_template_for_sam_cli() again. - - Validates: Requirement 24.1 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - params = {"Param1": "Value1"} - - # First call — cache miss, should call process_template_for_sam_cli - result1 = expand_language_extensions(template, parameter_values=params, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - self.assertTrue(result1.had_language_extensions) - - # Second call — cache hit, should NOT call process_template_for_sam_cli again - result2 = expand_language_extensions(template, parameter_values=params, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) # Still 1, not 2 - - # Both results should be the exact same object - self.assertIs(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_miss_on_different_template_path(self, mock_getmtime, mock_process): - """ - Test that a different template_path causes a cache miss and re-expansion. - - Validates: Requirement 24.2 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - params = {"Param1": "Value1"} - - # First call with path A - result1 = expand_language_extensions(template, parameter_values=params, template_path="/tmp/templateA.yaml") - self.assertEqual(mock_process.call_count, 1) - - # Second call with path B — different path, should be a cache miss - result2 = expand_language_extensions(template, parameter_values=params, template_path="/tmp/templateB.yaml") - self.assertEqual(mock_process.call_count, 2) - - # Results should NOT be the same object (different cache entries) - self.assertIsNot(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_miss_on_different_mtime(self, mock_getmtime, mock_process): - """ - Test that a different file mtime causes a cache miss and re-expansion. - - Validates: Requirement 24.3 - """ - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - params = {"Param1": "Value1"} - - # First call with mtime 1000.0 - mock_getmtime.return_value = 1000.0 - result1 = expand_language_extensions(template, parameter_values=params, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - - # Second call with mtime 2000.0 — file was modified, should be a cache miss - mock_getmtime.return_value = 2000.0 - result2 = expand_language_extensions(template, parameter_values=params, template_path=template_path) - self.assertEqual(mock_process.call_count, 2) - - # Results should NOT be the same object - self.assertIsNot(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_miss_on_different_parameter_values(self, mock_getmtime, mock_process): - """ - Test that different parameter values cause a cache miss and re-expansion. - - Validates: Requirement 24.4 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - - # First call with params A - result1 = expand_language_extensions( - template, parameter_values={"Param1": "ValueA"}, template_path=template_path - ) - self.assertEqual(mock_process.call_count, 1) - - # Second call with params B — different params, should be a cache miss - result2 = expand_language_extensions( - template, parameter_values={"Param1": "ValueB"}, template_path=template_path - ) - self.assertEqual(mock_process.call_count, 2) - - # Results should NOT be the same object - self.assertIsNot(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_hit_with_none_params(self, mock_getmtime, mock_process): - """ - Test that cache hit works correctly when parameter_values is None. - - Validates: Requirement 24.1 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - - # First call with None params - result1 = expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - - # Second call with None params — should be a cache hit - result2 = expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - - self.assertIs(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - def test_no_caching_without_template_path(self, mock_process): - """ - Test that caching is not used when template_path is not provided. - - Validates: Requirement 24.1 (cache requires template_path) - """ - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - - # First call without template_path - result1 = expand_language_extensions(template, parameter_values=None, template_path=None) - self.assertEqual(mock_process.call_count, 1) - - # Second call without template_path — no caching, should re-expand - result2 = expand_language_extensions(template, parameter_values=None, template_path=None) - self.assertEqual(mock_process.call_count, 2) - - # Results should NOT be the same object - self.assertIsNot(result1, result2) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_hit_for_non_language_extensions_template(self, mock_getmtime, mock_process): - """ - Test that non-language-extensions templates are also cached when template_path is provided. - - Validates: Requirement 24.1 - """ - mock_getmtime.return_value = 1000.0 - - template = { - "AWSTemplateFormatVersion": "2010-09-09", - "Transform": "AWS::Serverless-2016-10-31", - "Resources": { - "MyFunction": { - "Type": "AWS::Serverless::Function", - "Properties": {"Handler": "index.handler", "Runtime": "python3.9"}, - } - }, - } - template_path = "/tmp/template.yaml" - - # First call — no language extensions, but should cache - result1 = expand_language_extensions(template, template_path=template_path) - self.assertFalse(result1.had_language_extensions) - mock_process.assert_not_called() - - # Second call — should be a cache hit - result2 = expand_language_extensions(template, template_path=template_path) - self.assertIs(result1, result2) - mock_process.assert_not_called() - - -# ============================================================================= -# Task 36.2: Unit Tests for Cache Scoping and Invalidation -# ============================================================================= - - -class TestCacheScopingAndInvalidation(TestCase): - """ - Unit tests for cache scoping and invalidation. - - Validates: Requirements 24.5, 24.6 - """ - - def setUp(self): - """Clear the expansion cache before each test.""" - clear_expansion_cache() - - def tearDown(self): - """Clear the expansion cache after each test.""" - clear_expansion_cache() - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_clear_expansion_cache_clears_all_entries(self, mock_getmtime, mock_process): - """ - Test that clear_expansion_cache() removes all cached entries. - - Validates: Requirement 24.5 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - - # Populate cache with multiple entries - expand_language_extensions(template, parameter_values={"P": "1"}, template_path="/tmp/a.yaml") - expand_language_extensions(template, parameter_values={"P": "2"}, template_path="/tmp/b.yaml") - self.assertEqual(mock_process.call_count, 2) - - # Verify cache has entries - self.assertGreater(len(_expansion_cache), 0) - - # Clear the cache - clear_expansion_cache() - - # Verify cache is empty - self.assertEqual(len(_expansion_cache), 0) - - # Subsequent calls should re-expand (cache miss) - expand_language_extensions(template, parameter_values={"P": "1"}, template_path="/tmp/a.yaml") - self.assertEqual(mock_process.call_count, 3) - - def test_cache_is_empty_at_start_of_command(self): - """ - Test that the cache is empty after clear_expansion_cache() is called, - simulating the start of a new CLI command invocation. - - Validates: Requirement 24.5 - """ - # Simulate what happens at the start of each CLI command: - # clear_expansion_cache() is called - clear_expansion_cache() - - # Cache should be empty - self.assertEqual(len(_expansion_cache), 0) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_cache_empty_after_simulated_command_boundary(self, mock_getmtime, mock_process): - """ - Test that cache does not leak between simulated command invocations. - - Validates: Requirement 24.5 - """ - mock_getmtime.return_value = 1000.0 - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - - # Simulate first command: populate cache - expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - - # Simulate command boundary: clear cache (as done at start of each CLI command) - clear_expansion_cache() - - # Simulate second command: should re-expand (cache was cleared) - expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 2) - - @patch( - "samcli.lib.providers.sam_function_provider.SamLocalStackProvider.get_stacks", - return_value=([], None), - ) - def test_warm_container_refresh_calls_clear_expansion_cache(self, mock_get_stacks): - """ - Test that RefreshableSamFunctionProvider._refresh_loaded_functions() - calls clear_expansion_cache() to invalidate the cache on file changes. - - Validates: Requirement 24.6 - """ - from samcli.lib.providers.sam_function_provider import RefreshableSamFunctionProvider - from samcli.lib.providers.provider import Stack - - # Create a minimal root stack - root_stack = Stack( - parent_stack_path="", - name="", - location="/tmp/template.yaml", - parameters={}, - template_dict={ - "AWSTemplateFormatVersion": "2010-09-09", - "Resources": {}, - }, - metadata={}, - ) - - with patch.object(RefreshableSamFunctionProvider, "__init__", return_value=None): - provider = RefreshableSamFunctionProvider.__new__(RefreshableSamFunctionProvider) - # Set up minimal state needed for _refresh_loaded_functions - provider._stacks = [root_stack] - provider.parent_templates_paths = ["/tmp/template.yaml"] - provider._parameter_overrides = None - provider._global_parameter_overrides = None - provider.is_changed = True - provider._use_raw_codeuri = False - provider._ignore_code_extraction_warnings = False - provider._function_logical_ids = None - provider._observer = MagicMock() - - # Populate the cache with an entry - _expansion_cache[("/tmp/template.yaml", 1000.0, hash(()))] = LanguageExtensionResult( - expanded_template={}, - original_template={}, - dynamic_artifact_properties=[], - had_language_extensions=False, - ) - self.assertGreater(len(_expansion_cache), 0) - - # Call _refresh_loaded_functions which should clear the cache - provider._refresh_loaded_functions() - - # Cache should be cleared - self.assertEqual(len(_expansion_cache), 0) - - @patch("samcli.lib.cfn_language_extensions.sam_integration.process_template_for_sam_cli") - @patch("samcli.lib.cfn_language_extensions.sam_integration.os.path.getmtime") - def test_warm_container_file_change_invalidation_flow(self, mock_getmtime, mock_process): - """ - Test the full warm container file change invalidation flow: - 1. Template is expanded and cached - 2. File changes (mtime changes) - 3. Cache is cleared (simulating warm container refresh) - 4. Template is re-expanded with new mtime - - Validates: Requirement 24.6 - """ - mock_process.return_value = _make_expanded_template() - - template = _make_language_extensions_template() - template_path = "/tmp/template.yaml" - - # Step 1: Initial expansion with mtime 1000.0 - mock_getmtime.return_value = 1000.0 - result1 = expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - - # Step 2: Same call — cache hit - result2 = expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 1) - self.assertIs(result1, result2) - - # Step 3: Simulate file change — clear cache (as RefreshableSamFunctionProvider does) - clear_expansion_cache() - - # Step 4: File has new mtime after modification - mock_getmtime.return_value = 2000.0 - result3 = expand_language_extensions(template, parameter_values=None, template_path=template_path) - self.assertEqual(mock_process.call_count, 2) - - # Result should be a new object (re-expanded) - self.assertIsNot(result1, result3) - - -class TestHashParams(TestCase): - """ - Unit tests for the _hash_params helper function used in cache key computation. - """ - - def test_none_params_returns_consistent_hash(self): - """Test that None parameter values produce a consistent hash.""" - self.assertEqual(_hash_params(None), _hash_params(None)) - - def test_empty_dict_returns_consistent_hash(self): - """Test that empty dict parameter values produce a consistent hash.""" - self.assertEqual(_hash_params({}), _hash_params({})) - - def test_same_params_return_same_hash(self): - """Test that identical parameter values produce the same hash.""" - params = {"Param1": "Value1", "Param2": "Value2"} - self.assertEqual(_hash_params(params), _hash_params(params)) - - def test_different_params_return_different_hash(self): - """Test that different parameter values produce different hashes.""" - params_a = {"Param1": "ValueA"} - params_b = {"Param1": "ValueB"} - self.assertNotEqual(_hash_params(params_a), _hash_params(params_b)) - - def test_param_order_does_not_affect_hash(self): - """Test that parameter order does not affect the hash (sorted internally).""" - params_a = {"B": "2", "A": "1"} - params_b = {"A": "1", "B": "2"} - self.assertEqual(_hash_params(params_a), _hash_params(params_b)) - - def test_none_and_empty_produce_same_hash(self): - """Test that None and empty dict produce the same hash (both falsy).""" - self.assertEqual(_hash_params(None), _hash_params({})) From 70281b493c3212215c1b0f8f5b4006e77e073612 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Wed, 18 Feb 2026 20:39:24 +0000 Subject: [PATCH 20/21] fix: use OIDC credentials directly for sync test workflow --- .github/workflows/test-sync-fix.yml | 69 ++++++++++------------------- 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/.github/workflows/test-sync-fix.yml b/.github/workflows/test-sync-fix.yml index bdb80e32d9f..718846c1e88 100644 --- a/.github/workflows/test-sync-fix.yml +++ b/.github/workflows/test-sync-fix.yml @@ -15,8 +15,6 @@ env: NOSE_PARAMETERIZED_NO_WARN: 1 BY_CANARY: true UV_PYTHON: python3.11 - CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }} - ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }} jobs: sync-tests: @@ -82,45 +80,27 @@ jobs: - name: Initialize project run: make init - - name: Get testing resources and credentials + - name: Set up test resources run: | - test_env_var=$(python3.11 tests/get_testing_resources.py skip_role_deletion) - if [ $? -ne 0 ]; then - test_env_var=$(python3.11 tests/get_testing_resources.py) - if [ $? -ne 0 ]; then - echo "Failed to acquire credentials or test resources." - exit 1 - fi - fi - - echo "CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "CI_ACCESS_ROLE_AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV - - TEST_ACCESS_KEY_ID=$(echo "$test_env_var" | jq -j ".accessKeyID") - TEST_SECRET_ACCESS_KEY=$(echo "$test_env_var" | jq -j ".secretAccessKey") - TEST_SESSION_TOKEN=$(echo "$test_env_var" | jq -j ".sessionToken") - TEST_TASK_TOKEN=$(echo "$test_env_var" | jq -j ".taskToken") - - echo "::add-mask::$TEST_ACCESS_KEY_ID" - echo "::add-mask::$TEST_SECRET_ACCESS_KEY" - echo "::add-mask::$TEST_SESSION_TOKEN" - echo "::add-mask::$TEST_TASK_TOKEN" - - echo "AWS_ACCESS_KEY_ID=$TEST_ACCESS_KEY_ID" >> $GITHUB_ENV - echo "AWS_SECRET_ACCESS_KEY=$TEST_SECRET_ACCESS_KEY" >> $GITHUB_ENV - echo "AWS_SESSION_TOKEN=$TEST_SESSION_TOKEN" >> $GITHUB_ENV - echo "TASK_TOKEN=$TEST_TASK_TOKEN" >> $GITHUB_ENV - echo "AWS_S3_TESTING=$(echo "$test_env_var" | jq -j ".TestBucketName")" >> $GITHUB_ENV - echo "AWS_ECR_TESTING=$(echo "$test_env_var" | jq -j ".TestECRURI")" >> $GITHUB_ENV - echo "AWS_KMS_KEY=$(echo "$test_env_var" | jq -j ".TestKMSKeyArn")" >> $GITHUB_ENV - echo "AWS_SIGNING_PROFILE_NAME=$(echo "$test_env_var" | jq -j ".TestSigningProfileName")" >> $GITHUB_ENV - echo "AWS_SIGNING_PROFILE_VERSION_ARN=$(echo "$test_env_var" | jq -j ".TestSigningProfileARN")" >> $GITHUB_ENV + # Create S3 bucket and ECR repo for tests using OIDC credentials + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + BUCKET_NAME="sam-cli-sync-test-${ACCOUNT_ID}-${GITHUB_RUN_ID}" + ECR_REPO_URI="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/sam-cli-sync-test-${GITHUB_RUN_ID}" + + aws s3 mb "s3://${BUCKET_NAME}" || true + aws ecr create-repository --repository-name "sam-cli-sync-test-${GITHUB_RUN_ID}" || true + + echo "AWS_S3_TESTING=${BUCKET_NAME}" >> $GITHUB_ENV + echo "AWS_ECR_TESTING=${ECR_REPO_URI}" >> $GITHUB_ENV - name: Login to Public ECR run: | aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + - name: Login to Private ECR + run: | + aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com + - name: Run sync watch nested stack tests run: | pytest -vv tests/integration/sync/test_sync_watch.py -k "TestSyncInfraNestedStacks" --reruns 1 --json-report --json-report-file=TEST_REPORT-sync-nested.json @@ -132,16 +112,13 @@ jobs: name: test-results-sync-fix path: TEST_REPORT-*.json - - name: Reset test account + - name: Cleanup test resources if: always() run: | - export AWS_ACCESS_KEY_ID=$CI_ACCESS_ROLE_AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$CI_ACCESS_ROLE_AWS_SECRET_ACCESS_KEY - export AWS_SESSION_TOKEN=$CI_ACCESS_ROLE_AWS_SESSION_TOKEN - aws lambda invoke \ - --function-name "$ACCOUNT_RESET_LAMBDA_ARN" \ - --payload "{\"taskToken\": \"$TASK_TOKEN\", \"output\": \"{}\"}" \ - ./lambda-output.txt \ - --region us-west-2 \ - --cli-binary-format raw-in-base64-out - cat ./lambda-output.txt + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + aws s3 rb "s3://sam-cli-sync-test-${ACCOUNT_ID}-${GITHUB_RUN_ID}" --force || true + aws ecr delete-repository --repository-name "sam-cli-sync-test-${GITHUB_RUN_ID}" --force || true + # Clean up any CloudFormation stacks created by the tests + for stack in $(aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE --query "StackSummaries[?starts_with(StackName, 'test-sync-watch-infra-nested')].StackName" --output text); do + aws cloudformation delete-stack --stack-name "$stack" || true + done From f40bc6ca3860d3e82f17a6b616f203e06795bc96 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 16 Mar 2026 03:28:15 +0000 Subject: [PATCH 21/21] feat: add retry utility with exponential backoff --- samcli/lib/utils/retry.py | 60 +++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/samcli/lib/utils/retry.py b/samcli/lib/utils/retry.py index bfe8ccd4c26..e1dc4ab04d0 100644 --- a/samcli/lib/utils/retry.py +++ b/samcli/lib/utils/retry.py @@ -1,40 +1,44 @@ -""" -Retry decorator to retry decorated function based on Exception with exponential backoff and number of attempts built-in. -""" +"""Retry utility with exponential backoff for transient failures.""" -import math import time +import random +import logging from functools import wraps +LOG = logging.getLogger(__name__) -def retry(exc, attempts=3, delay=0.05, exc_raise=Exception, exc_raise_msg=""): - """ - Retry decorator which defaults to 3 attempts based on exponential backoff - and a delay of 50ms. - After retries are exhausted, a custom Exception and Error message are raised. - :param exc: Exception to be caught for retry - :param attempts: number of attempts before exception is allowed to be raised. - :param delay: an initial delay which will exponentially increase based on the retry attempt. - :param exc_raise: Final Exception to raise. - :param exc_raise_msg: Final message for the Exception to be raised. - :return: - """ +def retry_with_backoff(max_retries=3, base_delay=1.0, max_delay=30.0, exceptions=(Exception,)): + """Decorator that retries a function with exponential backoff. - def retry_wrapper(func): + Parameters + ---------- + max_retries : int + Maximum number of retry attempts + base_delay : float + Initial delay in seconds between retries + max_delay : float + Maximum delay cap in seconds + exceptions : tuple + Exception types that trigger a retry + """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - remaining_attempts = attempts - retry_attempt = 1 - while remaining_attempts >= 1: + last_exception = None + for attempt in range(max_retries + 1): try: return func(*args, **kwargs) - except exc: - time.sleep(math.pow(2, retry_attempt) * delay) - retry_attempt = retry_attempt + 1 - remaining_attempts = remaining_attempts - 1 - raise exc_raise(exc_raise_msg) - + except exceptions as e: + last_exception = e + if attempt == max_retries: + break + delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay) + LOG.warning( + "Attempt %d/%d failed for %s: %s. Retrying in %.1fs...", + attempt + 1, max_retries, func.__name__, str(e), delay + ) + time.sleep(delay) + raise last_exception return wrapper - - return retry_wrapper + return decorator